From 1b0484dec7f2c80067f2a25bb514b70a9687d6f2 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 29 Sep 2026 14:40:22 +0100 Subject: [PATCH 1/2] fix: allow safe payment request retries --- .../java/to/bitkit/ext/PaykitExceptionExt.kt | 3 + .../bitkit/repositories/PrivatePaykitRepo.kt | 90 +++++++++-- .../bitkit/repositories/PublicPaykitRepo.kt | 2 + .../screens/contacts/AddContactViewModel.kt | 4 +- .../contacts/ContactDetailViewModel.kt | 4 +- .../IncomingPaymentRequestDetailsScreen.kt | 34 ++-- .../paymentrequests/PaymentRequestsScreen.kt | 34 ++-- .../send/SendContactSelectViewModel.kt | 4 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 76 +++++++-- .../to/bitkit/ext/PaykitExceptionExtTest.kt | 12 ++ .../repositories/PrivatePaykitRepoTest.kt | 88 ++++++++++- .../PaymentRequestPresentationTest.kt | 82 ++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 146 ++++++++++++++++-- changelog.d/next/1370.fixed.md | 1 + journeys/README.md | 1 + journeys/payment-requests/README.md | 3 + .../safe-retry-after-failed-send.xml | 25 +++ 17 files changed, 534 insertions(+), 75 deletions(-) create mode 100644 app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt create mode 100644 changelog.d/next/1370.fixed.md create mode 100644 journeys/payment-requests/safe-retry-after-failed-send.xml diff --git a/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt b/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt index 33e1cbc09c..2bbc009f3f 100644 --- a/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt +++ b/app/src/main/java/to/bitkit/ext/PaykitExceptionExt.kt @@ -4,3 +4,6 @@ import com.synonym.paykit.PaykitException fun Throwable.isPaykitIdentityError(): Boolean = generateSequence(this) { it.cause }.any { it is PaykitException.Identity } + +fun Throwable.isPaykitRecoveryRequired(): Boolean = + generateSequence(this) { it.cause }.any { it is PaykitException.RecoveryRequired } diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt index 83d8e0f582..7505d35fdb 100644 --- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt @@ -36,6 +36,7 @@ import to.bitkit.data.PrivatePaykitCacheStore import to.bitkit.data.SettingsStore import to.bitkit.di.IoDispatcher import to.bitkit.di.json +import to.bitkit.ext.isPaykitRecoveryRequired import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.toHex import to.bitkit.models.PubkyPublicKeyFormat @@ -412,6 +413,29 @@ class PrivatePaykitRepo @Inject constructor( Logger.warn("Failed to consume private Paykit payment details", it, context = TAG) } + suspend fun releasePrivatePaymentList( + publicKey: String, + context: PrivatePaykitPaymentContext, + ): Result = withContext(serializedDispatcher) { + runSuspendCatching { + val normalizedKey = normalizedPublicKey(publicKey) ?: throw PrivatePaykitError.InvalidPublicKey + val contactState = ensureState().contacts[normalizedKey] ?: return@runSuspendCatching + val consumedVersion = contactState.consumedPrivatePaymentListVersionsByReceiverPath[context.receiverPath] + if (consumedVersion != context.paymentListVersion) return@runSuspendCatching + + contactState.consumedPrivatePaymentListVersionsByReceiverPath = + contactState.consumedPrivatePaymentListVersionsByReceiverPath - context.receiverPath + persistState(markWalletBackup = true) + Logger.info( + "Released private Paykit payment list version '${context.paymentListVersion}' " + + "for '${redacted(normalizedKey)}'", + context = TAG, + ) + } + }.onFailure { + Logger.warn("Failed to release private Paykit payment details", it, context = TAG) + } + suspend fun discardRemoteLightningEndpoints( publicKey: String, paymentHashes: Set, @@ -554,20 +578,32 @@ class PrivatePaykitRepo @Inject constructor( val amount = paymentRequest?.let { PaymentAmountContext(it.amountValue, PaykitIssuerInterop.BITCOIN_ASSET) } - val prepared = preparePrivateContactPayment( - publicKey = publicKey, - receiverPath = receiverPath, - consumedVersion = consumedVersion, - amount = amount, - allowPublicResolution = paymentRequest == null, - ) ?: return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() + val prepared = runSuspendCatching { + preparePrivateContactPayment( + publicKey = publicKey, + receiverPath = receiverPath, + consumedVersion = consumedVersion, + amount = amount, + allowPublicResolution = paymentRequest == null, + ) + }.getOrElse { + if (paymentRequest == null || !it.isPaykitRecoveryRequired()) throw it + if (paymentRequest.isExpired(clock.now())) throw PaykitPaymentRequestError.RequestExpired + return@runSuspendCatching privateLinkPendingResult(publicKey, receiverPath) + } ?: return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() val resolution = prepared.resolution val linkState = currentLinkState(publicKey, receiverPath, prepared.linkState) if (paymentRequest == null && canUsePublicPayment(linkState, resolution.status, resolution.state)) { return@runSuspendCatching publicPaykitRepo.beginPayment(publicKey).getOrThrow() } - val result = privatePaymentResult( + val result = unresolvedPrivateLinkResult( + publicKey = publicKey, + receiverPath = receiverPath, + paymentRequest = paymentRequest, + resolution = resolution, + linkState = linkState, + ) ?: privatePaymentResult( publicKey = publicKey, receiverPath = receiverPath, resolution = resolution, @@ -661,16 +697,11 @@ class PrivatePaykitRepo @Inject constructor( ) } - if ( - resolution.state == PrivatePaymentResolutionState.RECOVERY_PENDING || - resolution.status == PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST - ) { + if (resolution.status == PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST) { schedulePendingPrivateMessageDrainRetries( reason = "payment recovery", retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, receiverPath)), ) - } - if (resolution.status == PrivatePaymentResolutionStatus.WAITING_FOR_UPDATED_PAYMENT_LIST) { Logger.info( "Waiting for a private Paykit payment list newer than ${consumedVersion ?: "none"} " + "for '${redacted(publicKey)}'; public resolution is disabled for this request", @@ -686,6 +717,37 @@ class PrivatePaykitRepo @Inject constructor( } } + private fun unresolvedPrivateLinkResult( + publicKey: String, + receiverPath: String, + paymentRequest: PaykitPaymentRequest?, + resolution: PaykitPrivateContactPaymentResolution, + linkState: LinkedPeerState?, + ): PublicPaykitPaymentResult? = when { + resolution.state == PrivatePaymentResolutionState.RECOVERY_PENDING -> + privateLinkPendingResult(publicKey, receiverPath) + paymentRequest == null -> null + linkState == LinkedPeerState.LINKING || linkState == LinkedPeerState.RECOVERY_REQUIRED -> + privateLinkPendingResult(publicKey, receiverPath) + linkState != LinkedPeerState.LINKED -> PublicPaykitPaymentResult.NoEndpoint + else -> null + } + + private fun privateLinkPendingResult( + publicKey: String, + receiverPath: String, + ): PublicPaykitPaymentResult { + schedulePendingPrivateMessageDrainRetries( + reason = "payment link recovery", + retryKeys = listOf(PrivateMessageDrainRetryKey(publicKey, receiverPath)), + ) + Logger.info( + "Waiting for private Paykit link recovery for '${redacted(publicKey)}'", + context = TAG, + ) + return PublicPaykitPaymentResult.PrivateLinkPending + } + private suspend fun currentLinkState( publicKey: String, receiverPath: String, diff --git a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt index b55fff7326..342ab08650 100644 --- a/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PublicPaykitRepo.kt @@ -49,6 +49,7 @@ sealed interface PublicPaykitPaymentResult { data object NoEndpoint : PublicPaykitPaymentResult data object NotOpened : PublicPaykitPaymentResult + data object PrivateLinkPending : PublicPaykitPaymentResult data object WaitingForUpdatedPaymentList : PublicPaykitPaymentResult } @@ -77,6 +78,7 @@ internal val PublicPaykitPaymentResult.incomingPaymentRequestFailureReason: is PublicPaykitPaymentResult.Opened -> null PublicPaykitPaymentResult.NoEndpoint -> IncomingPaykitPaymentRequestFailureReason.NoSupportedEndpoint PublicPaykitPaymentResult.NotOpened -> IncomingPaykitPaymentRequestFailureReason.EndpointNotPayable + PublicPaykitPaymentResult.PrivateLinkPending, PublicPaykitPaymentResult.WaitingForUpdatedPaymentList -> IncomingPaykitPaymentRequestFailureReason.PaymentDetailsPending } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt index 8abd2e7b63..bb4e3920a9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/AddContactViewModel.kt @@ -126,7 +126,9 @@ class AddContactViewModel @Inject constructor( showPayError(R.string.slashtags__error_pay_empty_msg) PublicPaykitPaymentResult.NotOpened -> showPayError(R.string.slashtags__error_pay_not_opened_msg) - PublicPaykitPaymentResult.WaitingForUpdatedPaymentList -> + PublicPaykitPaymentResult.PrivateLinkPending, + PublicPaykitPaymentResult.WaitingForUpdatedPaymentList, + -> showPayError(R.string.slashtags__error_pay_waiting_msg) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt index 9f0c2d0cbd..d70f94182c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/contacts/ContactDetailViewModel.kt @@ -117,7 +117,9 @@ class ContactDetailViewModel @Inject constructor( showPayError(R.string.slashtags__error_pay_empty_msg) PublicPaykitPaymentResult.NotOpened -> showPayError(R.string.slashtags__error_pay_not_opened_msg) - PublicPaykitPaymentResult.WaitingForUpdatedPaymentList -> + PublicPaykitPaymentResult.PrivateLinkPending, + PublicPaykitPaymentResult.WaitingForUpdatedPaymentList, + -> showPayError(R.string.slashtags__error_pay_waiting_msg) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt index 758b918fa4..7f5242e6f0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt @@ -32,7 +32,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.synonym.paykit.PaymentRequestLifecycleState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -156,9 +155,18 @@ private fun IncomingPaymentRequestDetailsContent( text = "${request.detailsAmountPrefix()} $it".withAccent(accentColor = Colors.White64), ) FillWidth() - PaymentRequestDetailsIcon(request) + if (request.hasPaymentEvidence) { + PaymentRequestDetailsIcon(request) + } else { + PubkyContactAvatar(profile = contact, size = 48.dp) + } } } + BodyM( + text = paymentRequestStatus(request, isPending = isPending), + color = Colors.White64, + modifier = Modifier.testTag("PaymentRequestDetailsStatus") + ) VerticalSpacer(24.dp) Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { RequestDetailCell( @@ -292,27 +300,21 @@ private fun PaymentRequestTags( } } -private fun PaykitPaymentRequest.detailsAmountPrefix(): String = - if (direction == PaykitPaymentRequestDirection.Incoming) "-" else "+" +internal fun PaykitPaymentRequest.detailsAmountPrefix(): String = when { + !hasPaymentEvidence -> "" + direction == PaykitPaymentRequestDirection.Incoming -> "-" + else -> "+" +} @Composable private fun PaymentRequestDetailsIcon(request: PaykitPaymentRequest) { - val isCompleted = request.lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED val isIncomingRequest = request.direction == PaykitPaymentRequestDirection.Incoming CircularIcon( icon = painterResource( - if (isCompleted == isIncomingRequest) R.drawable.ic_sent else R.drawable.ic_received + if (isIncomingRequest) R.drawable.ic_sent else R.drawable.ic_received ), - iconColor = when { - isCompleted -> request.paymentRailIconColor - isIncomingRequest -> Colors.Purple - else -> Colors.Brand - }, - backgroundColor = when { - isCompleted -> request.paymentRailBackgroundColor - isIncomingRequest -> Colors.Purple16 - else -> Colors.Brand16 - }, + iconColor = request.paymentRailIconColor, + backgroundColor = request.paymentRailBackgroundColor, size = 48.dp, ) } 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 160b708161..abc149b0e9 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 @@ -2,6 +2,7 @@ package to.bitkit.ui.screens.paymentrequests +import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -332,7 +333,8 @@ internal fun PaymentRequestsContent( compactSubtitle = subscriptions.nameFor(request) ?: request.note?.takeIf(String::isNotBlank) ?: paymentRequestDate(request), - showSignedAmount = true, + showSignedAmount = request.hasPaymentEvidence, + fiatStatus = paymentRequestStatus(request), onClick = { onDetails(request.id) }, ) } @@ -472,21 +474,13 @@ internal fun paymentRequestDate(request: PaykitPaymentRequest): String = request } ?: paymentRequestStatus(request) @Composable -private fun paymentRequestStatus(request: PaykitPaymentRequest): String { +internal fun paymentRequestStatus(request: PaykitPaymentRequest, isPending: Boolean = false): String { if (request.lifecycleState == PaymentRequestLifecycleState.PROPOSED && request.isExpired(Clock.System.now())) { return stringResource(R.string.wallet__payment_request_status_expired) } return when (request.lifecycleState) { - PaymentRequestLifecycleState.PROPOSED -> { - if (request.direction == PaykitPaymentRequestDirection.Incoming) { - stringResource(R.string.wallet__payment_request_status_unavailable) - } else if (request.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent) { - stringResource(R.string.wallet__payment_request_waiting) - } else { - stringResource(R.string.wallet__payment_request_sending) - } - } + PaymentRequestLifecycleState.PROPOSED -> stringResource(proposedPaymentRequestStatusRes(request, isPending)) PaymentRequestLifecycleState.PROPOSAL_EXPIRED -> stringResource(R.string.wallet__payment_request_status_expired) PaymentRequestLifecycleState.ACCEPTED -> @@ -506,6 +500,17 @@ private fun paymentRequestStatus(request: PaykitPaymentRequest): String { } } +@StringRes +internal fun proposedPaymentRequestStatusRes(request: PaykitPaymentRequest, isPending: Boolean): Int = when { + request.direction == PaykitPaymentRequestDirection.Incoming && isPending -> + R.string.wallet__payment_request_waiting + request.direction == PaykitPaymentRequestDirection.Incoming -> + R.string.wallet__payment_request_status_unavailable + request.deliveryStatus == PaykitPaymentRequestDeliveryStatus.Sent -> + R.string.wallet__payment_request_waiting + else -> R.string.wallet__payment_request_sending +} + @Composable internal fun PaymentRequestCard( request: PaykitPaymentRequest, @@ -636,7 +641,10 @@ internal fun PaymentRequestCard( } } -private fun PaykitPaymentRequest.amountPrefix(isOutgoingPayment: Boolean, showSignedAmount: Boolean): String = when { +internal val PaykitPaymentRequest.hasPaymentEvidence: Boolean + get() = lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED + +internal fun PaykitPaymentRequest.amountPrefix(isOutgoingPayment: Boolean, showSignedAmount: Boolean): String = when { isOutgoingPayment -> "-" showSignedAmount && direction == PaykitPaymentRequestDirection.Incoming -> "-" showSignedAmount -> "+" @@ -663,7 +671,7 @@ internal val PaykitPaymentRequest.paymentRailBackgroundColor get() = if (paymentProofKind == PaykitPaymentProofKind.Lightning) Colors.Purple16 else Colors.Brand16 private fun PaykitPaymentRequest.showsPaymentRailIcon(isOutgoingPayment: Boolean): Boolean = - isOutgoingPayment || lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED + isOutgoingPayment || hasPaymentEvidence private fun PaykitPaymentRequest.paymentWasSent(isOutgoingPayment: Boolean): Boolean = isOutgoingPayment || direction == PaykitPaymentRequestDirection.Incoming diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt index f33ad7f686..12a0cb0dbc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendContactSelectViewModel.kt @@ -81,7 +81,9 @@ class SendContactSelectViewModel @Inject constructor( showPayError(R.string.slashtags__error_pay_empty_msg) PublicPaykitPaymentResult.NotOpened -> showPayError(R.string.slashtags__error_pay_not_opened_msg) - PublicPaykitPaymentResult.WaitingForUpdatedPaymentList -> + PublicPaykitPaymentResult.PrivateLinkPending, + PublicPaykitPaymentResult.WaitingForUpdatedPaymentList, + -> showPayError(R.string.slashtags__error_pay_waiting_msg) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index c95e9d15a7..77db4b01e7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1097,11 +1097,15 @@ class AppViewModel @Inject constructor( return false } if (result !is PublicPaykitPaymentResult.Opened) { - deferPaymentRequestPresentation( - request = request, - reason = result?.incomingPaymentRequestFailureReason - ?: IncomingPaykitPaymentRequestFailureReason.ResolutionFailed, - ) + if (result == PublicPaykitPaymentResult.PrivateLinkPending) { + finishPrivateLinkPendingPaymentRequestPresentation(request) + } else { + deferPaymentRequestPresentation( + request = request, + reason = result?.incomingPaymentRequestFailureReason + ?: IncomingPaykitPaymentRequestFailureReason.ResolutionFailed, + ) + } return false } @@ -1116,6 +1120,27 @@ class AppViewModel @Inject constructor( return true } + private fun finishPrivateLinkPendingPaymentRequestPresentation(request: PaykitPaymentRequest) { + paykitPaymentRequestDiagnostics.logPresentationRejection( + request.counterparty, + IncomingPaykitPaymentRequestFailureReason.PaymentDetailsPending, + ) + val isRequested = requestedPaymentRequestId == request.id + val restorePaymentRequestSheet = isRequested && shouldRestorePaymentRequestSheet + if (isRequested) { + paymentRequestPresentationGeneration++ + clearRequestedPaymentRequest() + } + clearPaymentRequestPresentationRetry(request.id) + if (!isRequested) return + toast( + type = Toast.ToastType.INFO, + title = context.getString(R.string.wallet__payment_request), + description = context.getString(R.string.wallet__payment_request_waiting_for_details), + ) + if (restorePaymentRequestSheet && currentSheet.value == null) showSheet(Sheet.PaymentRequests) + } + private fun isCurrentPaymentRequestPresentation(request: PaykitPaymentRequest, generation: Long): Boolean = activePaymentRequestPresentationGeneration == generation && paymentRequestPresentationGeneration == generation && @@ -4003,6 +4028,7 @@ class AppViewModel @Inject constructor( } acceptIncomingPaymentRequestIfNeeded(contactPaymentContext).onFailure { + releasePrivatePaymentListIfNeeded(contactPaymentContext) cancelPaymentProofPreparation(preparedPaymentProofRequest) handlePaymentPreparationFailure(it, contactPaymentContext) return @@ -4024,6 +4050,7 @@ class AppViewModel @Inject constructor( it.copy(decodedInvoice = invoice) } }.onFailure { + releasePrivatePaymentListIfNeeded(contactPaymentContext) cancelPaymentProofPreparation(preparedPaymentProofRequest) val message = getLnurlInvoiceFetchErrorMessage(it) handlePaymentPreparationFailure(AppError(message, it), contactPaymentContext) @@ -4042,6 +4069,7 @@ class AppViewModel @Inject constructor( SendMethod.LIGHTNING -> proceedWithLightningPayment( incomingPaymentRequest, preparedPaymentProofRequest, + contactPaymentContext, amount, ) } @@ -4056,7 +4084,8 @@ class AppViewModel @Inject constructor( val address = _sendUiState.value.address val tags = _sendUiState.value.selectedTags var proofRequest = preparedPaymentProofRequest - var onchainPaymentStarted = false + var paymentProofStarted = false + var sendAttempted = false sendOnchain( address = address, amount = amount, @@ -4064,8 +4093,9 @@ class AppViewModel @Inject constructor( beforeSendAttempt = { if (preparedPaymentProofRequest != null) { markOnchainPaymentStarted(incomingPaymentRequest, address).getOrThrow() - onchainPaymentStarted = true + paymentProofStarted = true } + sendAttempted = true }, onBroadcast = { txId -> proofRequest = null @@ -4088,7 +4118,8 @@ class AppViewModel @Inject constructor( }.onFailure { error -> handleOnchainPaymentFailure( error = error, - paymentStarted = onchainPaymentStarted, + sendAttempted = sendAttempted, + paymentProofStarted = paymentProofStarted, incomingPaymentRequest = incomingPaymentRequest, preparedPaymentProofRequest = proofRequest, contactPaymentContext = contactPaymentContext, @@ -4098,31 +4129,37 @@ class AppViewModel @Inject constructor( private suspend fun handleOnchainPaymentFailure( error: Throwable, - paymentStarted: Boolean, + sendAttempted: Boolean, + paymentProofStarted: Boolean, incomingPaymentRequest: PaykitPaymentRequest?, preparedPaymentProofRequest: PaykitPaymentRequest?, contactPaymentContext: ContactPaymentContext?, ) { val amount = _sendUiState.value.amount - if (paymentStarted && !error.isDefiniteOnchainPreBroadcastFailure()) { + if ( + incomingPaymentRequest != null && + sendAttempted && + !error.isDefiniteOnchainPreBroadcastFailure() + ) { Logger.warn("On-chain payment outcome is uncertain after send started", error, context = TAG) - uncertainOnchainPaymentRequestId = incomingPaymentRequest?.id + uncertainOnchainPaymentRequestId = incomingPaymentRequest.id paykitPaymentProofRepo.onchainPaymentResolutions.value - .firstOrNull { it.requestId == incomingPaymentRequest?.id } + .firstOrNull { it.requestId == incomingPaymentRequest.id } ?.let(::handlePaykitOnchainPaymentResolution) if (uncertainOnchainPaymentRequestId == null) return setSendEffect( SendEffect.NavigateToPending( - paymentHash = incomingPaymentRequest?.paymentRequestId.orEmpty(), + paymentHash = incomingPaymentRequest.paymentRequestId, amount = amount.toLong(), observeResolution = false, ) ) return } - if (paymentStarted) { + if (paymentProofStarted) { incomingPaymentRequest?.let { paykitPaymentProofRepo.failOnchainPayment(it) } } + releasePrivatePaymentListIfNeeded(contactPaymentContext) cancelPaymentProofPreparation(preparedPaymentProofRequest) Logger.error("Error sending onchain payment", error, context = TAG) if (contactPaymentContext?.isInitialSubscriptionPayment == true) { @@ -4137,9 +4174,11 @@ class AppViewModel @Inject constructor( } } + @Suppress("LongMethod") private suspend fun proceedWithLightningPayment( incomingPaymentRequest: PaykitPaymentRequest?, preparedPaymentProofRequest: PaykitPaymentRequest?, + contactPaymentContext: ContactPaymentContext?, amount: ULong, ) { val decodedInvoice = requireNotNull(_sendUiState.value.decodedInvoice) @@ -4192,6 +4231,7 @@ class AppViewModel @Inject constructor( lnurlComment?.let { activityRepo.setLightningMessageIfEmpty(paymentHash, it) } return@onFailure } + releasePrivatePaymentListIfNeeded(contactPaymentContext) cancelPaymentProofPreparation(proofRequest) createdMetadataPaymentId?.let { preActivityMetadataRepo.deletePreActivityMetadata(it) } lnurlComment?.let { activityRepo.clearPendingLightningMessage(paymentHash) } @@ -4238,6 +4278,7 @@ class AppViewModel @Inject constructor( return false } acceptIncomingPaymentRequestIfNeeded(contactPaymentContext).onFailure { + releasePrivatePaymentListIfNeeded(contactPaymentContext) handlePaymentPreparationFailure(it, contactPaymentContext) return false } @@ -5165,6 +5206,7 @@ class AppViewModel @Inject constructor( synchronized(contactPaymentContextLock) { if (preparedContactPaymentContext == contactPaymentContext) preparedContactPaymentContext = null } + releasePrivatePaymentListIfNeeded(contactPaymentContext) cancelPaymentProofPreparation(preparedPaymentProofRequest) handlePaymentPreparationFailure(it, contactPaymentContext) return false @@ -5259,6 +5301,12 @@ class AppViewModel @Inject constructor( return privatePaykitRepo.consumePrivatePaymentList(context.publicKey, privatePaymentContext) } + private suspend fun releasePrivatePaymentListIfNeeded(context: ContactPaymentContext?) { + if (context?.incomingPaymentRequest == null) return + val privatePaymentContext = context.privatePaymentContext ?: return + privatePaykitRepo.releasePrivatePaymentList(context.publicKey, privatePaymentContext) + } + private suspend fun acceptIncomingPaymentRequestIfNeeded(context: ContactPaymentContext?): Result { val request = context?.incomingPaymentRequest ?: return Result.success(Unit) return paykitPaymentRequestRepo.accept(request) diff --git a/app/src/test/java/to/bitkit/ext/PaykitExceptionExtTest.kt b/app/src/test/java/to/bitkit/ext/PaykitExceptionExtTest.kt index 8248de63dd..a60831a5c5 100644 --- a/app/src/test/java/to/bitkit/ext/PaykitExceptionExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/PaykitExceptionExtTest.kt @@ -25,4 +25,16 @@ class PaykitExceptionExtTest { fun `generic failures are not identity errors`() { assertFalse(AppError("Native load failed").isPaykitIdentityError()) } + + @Test + fun `wrapped recovery failures require recovery`() { + val error = AppError(PaykitException.RecoveryRequired("recovery_required", "Handshake is in progress")) + + assertTrue(error.isPaykitRecoveryRequired()) + } + + @Test + fun `generic failures do not require recovery`() { + assertFalse(AppError("Native load failed").isPaykitRecoveryRequired()) + } } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 4bc0d724b0..d55a62c89a 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -8,6 +8,7 @@ import com.synonym.paykit.ContactRecord import com.synonym.paykit.CounterpartyReceiver import com.synonym.paykit.LinkedPeerRecord import com.synonym.paykit.LinkedPeerState +import com.synonym.paykit.PaykitException import com.synonym.paykit.PaymentAmountContext import com.synonym.paykit.PrivatePaymentListDeliveryReport import com.synonym.paykit.PrivatePaymentListReservationUpdateInput @@ -63,6 +64,7 @@ import to.bitkit.utils.AppError import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime @@ -1228,7 +1230,66 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() - assertEquals(PublicPaykitPaymentResult.NoEndpoint, result) + assertEquals(PublicPaykitPaymentResult.PrivateLinkPending, result) + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + + @Test + fun `beginPaymentRequest waits for a linking peer before opening cached details`() = test { + val request = paymentRequest() + whenever { + paykitSdkService.prepareAndResolvePrivateContactPayment( + eq(CONTACT_KEY), + eq(SERVER_RECEIVER_PATH), + eq(null), + any(), + ) + }.thenReturn( + resolution( + resolvedEndpoint(MethodId.Bolt11, SERVER_PRIVATE_BOLT11), + version = 7uL, + linkState = LinkedPeerState.LINKING, + ), + resolution( + resolvedEndpoint(MethodId.Bolt11, SERVER_PRIVATE_BOLT11), + version = 7uL, + linkState = LinkedPeerState.LINKED, + ), + ) + whenever(coreService.decode(SERVER_PRIVATE_BOLT11)) + .thenReturn(Scanner.Lightning(lightningInvoice(SERVER_PRIVATE_BOLT11, byteArrayOf(8, 8, 8)))) + + val pending = sut.beginPaymentRequest(request).getOrThrow() + val opened = sut.beginPaymentRequest(request).getOrThrow() + + assertEquals(PublicPaykitPaymentResult.PrivateLinkPending, pending) + assertEquals( + PublicPaykitPaymentResult.Opened( + paymentRequest = SERVER_PRIVATE_BOLT11, + privatePaymentContext = PrivatePaykitPaymentContext(SERVER_RECEIVER_PATH, 7uL), + ), + opened, + ) + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + + @Test + fun `beginPaymentRequest keeps typed recovery failures pending`() = test { + val request = paymentRequest() + whenever { + paykitSdkService.prepareAndResolvePrivateContactPayment( + eq(CONTACT_KEY), + eq(SERVER_RECEIVER_PATH), + eq(null), + any(), + ) + }.doSuspendableAnswer { + throw PaykitException.RecoveryRequired("recovery_required", "Handshake is in progress") + } + + val result = sut.beginPaymentRequest(request).getOrThrow() + + assertEquals(PublicPaykitPaymentResult.PrivateLinkPending, result) verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } @@ -1308,6 +1369,31 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { } } + @Test + fun `releasePrivatePaymentList makes matching version reusable without clearing newer consumption`() = test { + val releasedContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 7uL) + val newerContext = PrivatePaykitPaymentContext(WALLET_RECEIVER_PATH, 8uL) + + sut.consumePrivatePaymentList(CONTACT_KEY, releasedContext).getOrThrow() + sut.releasePrivatePaymentList(CONTACT_KEY, releasedContext).getOrThrow() + + assertNull( + cacheData.value.contacts[CONTACT_KEY] + ?.consumedPrivatePaymentListVersionsByReceiverPath + ?.get(WALLET_RECEIVER_PATH), + ) + sut.consumePrivatePaymentList(CONTACT_KEY, releasedContext).getOrThrow() + sut.consumePrivatePaymentList(CONTACT_KEY, newerContext).getOrThrow() + + sut.releasePrivatePaymentList(CONTACT_KEY, releasedContext).getOrThrow() + + assertEquals( + 8uL, + cacheData.value.contacts.getValue(CONTACT_KEY) + .consumedPrivatePaymentListVersionsByReceiverPath[WALLET_RECEIVER_PATH], + ) + } + @Test fun `beginSavedContactPayment passes consumed list version to private resolver`() = test { sut.prepareSavedContacts(listOf(CONTACT_KEY)) diff --git a/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt b/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt new file mode 100644 index 0000000000..ed3e535969 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt @@ -0,0 +1,82 @@ +package to.bitkit.ui.screens.paymentrequests + +import com.synonym.paykit.PaymentRequestLifecycleState +import org.junit.Test +import to.bitkit.R +import to.bitkit.repositories.PaykitPaymentProofKind +import to.bitkit.repositories.PaykitPaymentRequest +import to.bitkit.repositories.PaykitPaymentRequestDirection +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PaymentRequestPresentationTest { + @Test + fun `unpaid request amounts are not signed as money movement`() { + val unpaidStates = listOf( + PaymentRequestLifecycleState.ACCEPTED, + PaymentRequestLifecycleState.REJECTED, + PaymentRequestLifecycleState.CANCELED, + PaymentRequestLifecycleState.PROPOSAL_EXPIRED, + PaymentRequestLifecycleState.RECOVERY_REQUIRED, + ) + + unpaidStates.forEach { lifecycleState -> + val request = paymentRequest(lifecycleState = lifecycleState) + + assertFalse(request.hasPaymentEvidence) + assertEquals( + "", + request.amountPrefix(isOutgoingPayment = false, showSignedAmount = request.hasPaymentEvidence), + ) + assertEquals("", request.detailsAmountPrefix()) + } + } + + @Test + fun `paid request amounts preserve their movement direction`() { + val incoming = paymentRequest( + lifecycleState = PaymentRequestLifecycleState.PROOF_SUBMITTED, + direction = PaykitPaymentRequestDirection.Incoming, + ) + val outgoing = incoming.copy(direction = PaykitPaymentRequestDirection.Outgoing) + + assertTrue(incoming.hasPaymentEvidence) + assertEquals("-", incoming.amountPrefix(isOutgoingPayment = false, showSignedAmount = true)) + assertEquals("-", incoming.detailsAmountPrefix()) + assertEquals("+", outgoing.amountPrefix(isOutgoingPayment = false, showSignedAmount = true)) + assertEquals("+", outgoing.detailsAmountPrefix()) + } + + @Test + fun `live pending incoming request shows waiting status`() { + val request = paymentRequest(lifecycleState = PaymentRequestLifecycleState.PROPOSED) + + assertEquals( + R.string.wallet__payment_request_waiting, + proposedPaymentRequestStatusRes(request = request, isPending = true), + ) + assertEquals( + R.string.wallet__payment_request_status_unavailable, + proposedPaymentRequestStatusRes(request = request, isPending = false), + ) + } + + private fun paymentRequest( + lifecycleState: PaymentRequestLifecycleState, + direction: PaykitPaymentRequestDirection = PaykitPaymentRequestDirection.Incoming, + ) = PaykitPaymentRequest( + paymentRequestId = "request-id", + counterparty = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg", + counterpartyReceiverPath = "bitkit/server", + amountValue = "0.000025", + amountSats = 2_500uL, + expiresAt = null, + acceptedPaymentEndpointIdentifiers = listOf("lightning-bolt11"), + direction = direction, + lifecycleState = lifecycleState, + paymentProofKind = PaykitPaymentProofKind.Lightning.takeIf { + lifecycleState == PaymentRequestLifecycleState.PROOF_SUBMITTED + }, + ) +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 27b8dc61e9..4dfe1bb952 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -419,6 +419,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { .thenReturn(Result.success(Unit)) whenever { privatePaykitRepo.handleReceivedPayment(any()) } .thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.releasePrivatePaymentList(any(), any()) } + .thenReturn(Result.success(Unit)) whenever { notifyPaymentReceivedHandler(any()) } .thenReturn(Result.success(NotifyPaymentReceived.Result.Skip)) whenever { notifyPaymentReceivedHandler.present(any(), any(), any()) }.thenAnswer { @@ -945,6 +947,44 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) } + @Test + fun `private link recovery releases manual request actions`() = 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(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( + Result.success(PublicPaykitPaymentResult.PrivateLinkPending) + ) + whenever { paykitPaymentRequestRepo.dismiss(request) }.thenReturn(Result.success(Unit)) + pendingPaykitPaymentRequests.value = listOf(request) + surfacedPaykitPaymentRequestIds += request.id + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + runCurrent() + + sut.showPaymentRequests() + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + + assertEquals(Sheet.PaymentRequests, sut.currentSheet.value) + verify(privatePaykitRepo).beginPaymentRequest(request) + verify(paykitPaymentRequestRepo, never()).markPresented(request) + + sut.openIncomingPaymentRequest(request.id) + advanceTimeBy(TRANSITION_SCREEN_MS) + runCurrent() + sut.dismissIncomingPaymentRequest(request).getOrThrow() + + verify(privatePaykitRepo, times(2)).beginPaymentRequest(request) + verify(paykitPaymentRequestRepo).dismiss(request) + verify(paykitPaymentRequestDiagnostics, times(2)).logPresentationRejection( + request.counterparty, + IncomingPaykitPaymentRequestFailureReason.PaymentDetailsPending, + ) + } + @Test fun `unaffordable request releases manual presentation state`() = test { val request = paymentRequest() @@ -6268,6 +6308,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(privatePaykitRepo).consumePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentRequestRepo).accept(request) } + verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) } @@ -6314,6 +6355,42 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) } + @Test + fun `uncertain onchain outcome without prepared proof keeps private payment details consumed`() = test { + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(paykitPaymentProofRepo.prepare(request, MethodId.P2wpkh.rawValue, PaykitPaymentProofKind.Onchain)) + .thenReturn(Result.failure(IllegalStateException("proof unavailable"))) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + stubOnchainSend( + address = "bcrt1quncertainwithoutproof", + sats = request.amountSats, + result = Result.failure(IllegalStateException("outcome unknown")), + ) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = "bcrt1quncertainwithoutproof", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ), + ) + + sut.sendEffect.test { + confirmCurrentPayment() + + assertTrue(awaitItem() is SendEffect.NavigateToPending) + } + verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) + } + @Test fun `proof association failure does not block incoming lightning payment`() = test { val request = paymentRequest() @@ -6394,6 +6471,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } verify(paykitPaymentProofRepo, never()).failLightningPayment(any()) verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test @@ -6439,6 +6517,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) verify(paykitPaymentProofRepo).failLightningPayment(invoicePaymentHash, error) + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).cancelPreparation(request) } } @@ -6480,6 +6559,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(pendingPaymentRepo).track(paymentHash) verify(paykitPaymentProofRepo, never()).failLightningPayment(any()) verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test @@ -6586,6 +6666,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() verify(paykitPaymentProofRepo).failOnchainPayment(request) + verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test @@ -6614,6 +6695,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() verify(paykitPaymentProofRepo).failOnchainPayment(request) + verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test @@ -6678,6 +6760,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { finishCompletion.complete(Unit) advanceUntilIdle() verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test @@ -6911,21 +6994,45 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `onchain payment failure before send attempt cancels prepared proof`() = test { + fun `uncertain ordinary onchain failure keeps existing error handling`() = test { + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + stubOnchainSend( + address = "bcrt1quncertainordinarysend", + sats = 1_000uL, + result = Result.failure(IllegalStateException("outcome unknown")), + ) + setSendState( + SendUiState( + address = "bcrt1quncertainordinarysend", + amount = 1_000uL, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + ), + ) + sut.showSheet(Sheet.Send()) + advanceUntilIdle() + + confirmCurrentPayment() + + verify(toastManager).enqueue(any()) + assertNull(sut.currentSheet.value) + } + + @Test + fun `onchain payment failure before send attempt releases private payment details`() = test { val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) stubOnchainSend( address = "bcrt1qpreflightfailure", sats = request.amountSats, result = Result.failure(IllegalStateException("preflight failed")), invokeBeforeSendAttempt = false, ) - setActiveContactPaymentContext( - testPublicKey, - incomingPaymentRequest = request, - isInitialSubscriptionPayment = true, - ) + setActiveContactPaymentContext(testPublicKey, privateContext, request, isInitialSubscriptionPayment = true) setSendState( SendUiState( address = "bcrt1qpreflightfailure", @@ -6942,21 +7049,26 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertTrue(awaitItem() is SendEffect.NavigateToError) } + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).cancelPreparation(request) verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) } @Test - fun `definite onchain failure after send attempt allows proof retry`() = test { + fun `definite onchain failure after send attempt releases private payment details`() = test { for (error in listOf( NodeException.InvalidAddress("invalid address"), + NodeException.InsufficientFunds("insufficient funds"), NodeException.WalletOperationFailed("wallet"), NodeException.PersistenceFailed("io"), )) { - clearInvocations(paykitPaymentProofRepo) + clearInvocations(paykitPaymentProofRepo, privatePaykitRepo) val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) stubOnchainSend( address = "bcrt1qdefinitefailure", sats = request.amountSats, @@ -6964,6 +7076,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) setActiveContactPaymentContext( testPublicKey, + privateContext, incomingPaymentRequest = request, isInitialSubscriptionPayment = true, ) @@ -6983,6 +7096,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertTrue(awaitItem() is SendEffect.NavigateToError) } + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).failOnchainPayment(request) } } @@ -6990,20 +7104,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `uncertain onchain failure resolves the matching pending payment`() = test { val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) pubkyPublicKey.value = testPublicKey runCurrent() balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) stubOnchainSend( address = "bcrt1quncertainfailure", sats = request.amountSats, result = Result.failure(IllegalStateException("outcome unknown")), ) - setActiveContactPaymentContext( - testPublicKey, - incomingPaymentRequest = request, - isInitialSubscriptionPayment = true, - ) + setActiveContactPaymentContext(testPublicKey, privateContext, request, isInitialSubscriptionPayment = true) setSendState( SendUiState( address = "bcrt1quncertainfailure", @@ -7046,6 +7159,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test @@ -7131,15 +7245,18 @@ class AppViewModelSendFlowTest : BaseUnitTest() { @Test fun `post broadcast bookkeeping failure still completes payment proof`() = test { val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) stubOnchainSend( address = "bcrt1qbookkeepingfailure", sats = request.amountSats, result = Result.failure(IllegalStateException("activity persistence failed")), broadcastTxId = "broadcast-txid", ) - setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request) + setActiveContactPaymentContext(testPublicKey, privateContext, request) setSendState( SendUiState( address = "bcrt1qbookkeepingfailure", @@ -7154,6 +7271,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo).completeOnchainPayment(request, "broadcast-txid", MethodId.P2wpkh.rawValue) verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) + verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test diff --git a/changelog.d/next/1370.fixed.md b/changelog.d/next/1370.fixed.md new file mode 100644 index 0000000000..b316ae29dc --- /dev/null +++ b/changelog.d/next/1370.fixed.md @@ -0,0 +1 @@ +Fixed private payment requests remaining unavailable after safe send failures, blocking actions during link recovery, and displaying unpaid requests as completed payments. diff --git a/journeys/README.md b/journeys/README.md index b481acfc01..e99ebf9e30 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -153,6 +153,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `activity/date-range-rapid-month-taps.xml` | not ported — iOS has no activity journey suite, and the rapid month tap behaviour was not checked there | | `coin-selection/manual-coin-selection.xml` | not ported — iOS has the screen (`SendUtxoSelectionView`) but no accessibility identifiers on it yet | | `payment-requests/requested-resolution-failure.xml` | not ported | +| `payment-requests/safe-retry-after-failed-send.xml` | not ported — the iOS retry fix is pending in synonymdev/bitkit-ios#826 | | `node-lifecycle/cancelled-node-restart.xml` | not ported — the routes run through Android's LDK Debug and Rapid-Gossip-Sync screens and assert on Android app-log lines | | `restore-wallet/paste-seed-fragment.xml` | not ported — the iOS Restore screen still has the 12/24-only paste guard, so the behaviour does not exist there yet | | `send/own-invoice-guard.xml` | not ported — iOS has no own-invoice guard | diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index a4f2c609b4..93918eb48b 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -20,6 +20,8 @@ Rejected fixture shapes stay in unit tests because Bitkit intentionally does not `request-summary.xml` uses a second Bitkit instance as the requester instead of the fixture issuer: both instances are authenticated Pubky identities, saved as each other's contacts and linked on receiver path `bitkit/wallet`, and the payer holds enough balance to pay 21,000 sats. +`safe-retry-after-failed-send.xml` uses a second Bitkit instance restored from the payer's seed to reduce the shared wallet balance after the first instance has opened the 100,000 sat request. Keep Paykit disabled on the second instance. Mine the balance-reduction transaction and wait for the first instance to sync before swiping, so the request passes initial validation but fails before broadcast. Then add regtest funds through `./lsp`, mine blocks, and wait for the payer to sync before retrying the same request. + ## Reference evidence The source wallet-leg run completed this path on regtest on 2026-08-22: Bitkit presented the incoming request, opened the on-chain payment, broadcast it, and confirmed transaction @@ -39,5 +41,6 @@ That run established the issuer shapes captured by the fixture: lowercase `btc`, - Confirmation invoice note: `PaymentRequestInvoiceNote` - Confirmation details: `SendConfirmToggleDetails` - Saved-contact recipient: `ReviewContactRecipient` +- Swipe confirmation control: `GRAB` `android layout` can omit test tags applied to plain `Box` and `Column` containers. Use the raw UI Automator hierarchy when a documented container tag is not present in the formatted layout output. diff --git a/journeys/payment-requests/safe-retry-after-failed-send.xml b/journeys/payment-requests/safe-retry-after-failed-send.xml new file mode 100644 index 0000000000..289d0eb351 --- /dev/null +++ b/journeys/payment-requests/safe-retry-after-failed-send.xml @@ -0,0 +1,25 @@ + + + Verifies that a private Paykit Payment Request remains payable when its first on-chain send + fails before broadcast. Requires the linked fixture issuer described in README.md, a 100,000 + sat request, and a second Bitkit instance restored from the payer's seed. The shared wallet must + initially cover the request and its fee. Keep Paykit disabled on the second instance so it does + not publish for the payer identity. The fixture must keep the original private payment list + available during the retry. + + + Open the incoming request from the pending Payment Requests bell (testTag "PaymentRequestsBell") + Verify the request row (testTag "PaymentRequestRow-<payment-request-id>") is visible, then tap Pay (testTag "PaymentRequestPay-<payment-request-id>") + Verify the Payment Request confirmation screen (testTag "PaymentRequestConfirm") appears + On the second Bitkit instance, send enough of the shared wallet's funds to an unrelated regtest address that less than the request plus its fee remains, then mine blocks and wait until the payer instance reflects the lower balance + Swipe the confirmation control (testTag "GRAB") to pay + Verify the over-half-balance warning (testTag "SendDialog2") appears and tap Yes (testTag "DialogConfirm"); confirm any additional send warning with the same button + Verify the send fails before broadcast, no transaction pays the request address, and the Payment Requests bell (testTag "PaymentRequestsBell") still exposes the request + Fund the payer with enough additional regtest sats to cover the request and fee, then mine blocks and wait for the balance to sync + Open Payment Requests and tap Pay on the same request (testTag "PaymentRequestPay-<payment-request-id>") + Verify the Payment Request confirmation screen (testTag "PaymentRequestConfirm") appears again without an operation-in-progress or unavailable error + Swipe the confirmation control (testTag "GRAB") to pay + Confirm any send warning that appears (testTag "DialogConfirm") + Verify the payment succeeds and exactly one transaction pays the request address + + From f0a73efd2dbc9bbb22dc842312fd0468dcf605e5 Mon Sep 17 00:00:00 2001 From: benk10 Date: Wed, 30 Sep 2026 03:34:30 +0100 Subject: [PATCH 2/2] fix: address payment request review feedback --- .../IncomingPaymentRequestDetailsScreen.kt | 6 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../repositories/PrivatePaykitRepoTest.kt | 33 +++++- .../PaymentRequestPresentationTest.kt | 3 + .../viewmodels/AppViewModelSendFlowTest.kt | 108 +++++++++++++++++- journeys/README.md | 1 + journeys/payment-requests/README.md | 2 + .../issuer-interoperability.xml | 7 +- 8 files changed, 151 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt index 7f5242e6f0..fc6ae324d3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/paymentrequests/IncomingPaymentRequestDetailsScreen.kt @@ -152,7 +152,8 @@ private fun IncomingPaymentRequestDetailsContent( )?.let { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Display( - text = "${request.detailsAmountPrefix()} $it".withAccent(accentColor = Colors.White64), + text = request.detailsAmountText(it).withAccent(accentColor = Colors.White64), + modifier = Modifier.testTag("PaymentRequestDetailsAmount") ) FillWidth() if (request.hasPaymentEvidence) { @@ -306,6 +307,9 @@ internal fun PaykitPaymentRequest.detailsAmountPrefix(): String = when { else -> "+" } +internal fun PaykitPaymentRequest.detailsAmountText(amount: String): String = + "${detailsAmountPrefix()} $amount".trimStart() + @Composable private fun PaymentRequestDetailsIcon(request: PaykitPaymentRequest) { val isIncomingRequest = request.direction == PaykitPaymentRequestDirection.Incoming diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 68a9d60a6e..4538977830 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1137,7 +1137,7 @@ class AppViewModel @Inject constructor( toast( type = Toast.ToastType.INFO, title = context.getString(R.string.wallet__payment_request), - description = context.getString(R.string.wallet__payment_request_waiting_for_details), + description = context.getString(R.string.slashtags__error_pay_waiting_msg), ) if (restorePaymentRequestSheet && currentSheet.value == null) showSheet(Sheet.PaymentRequests) } diff --git a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt index 789388633c..543d6b622d 100644 --- a/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt @@ -1300,14 +1300,14 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { @Test fun `beginPaymentRequest waits for a linking peer before opening cached details`() = test { val request = paymentRequest() - whenever { + whenever( paykitSdkService.prepareAndResolvePrivateContactPayment( eq(CONTACT_KEY), eq(SERVER_RECEIVER_PATH), eq(null), any(), ) - }.thenReturn( + ).thenReturn( resolution( resolvedEndpoint(MethodId.Bolt11, SERVER_PRIVATE_BOLT11), version = 7uL, @@ -1336,17 +1336,42 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } } + @Test + fun `beginPaymentRequest rejects cached details when the peer is not linked`() = test { + val request = paymentRequest() + whenever( + paykitSdkService.prepareAndResolvePrivateContactPayment( + eq(CONTACT_KEY), + eq(SERVER_RECEIVER_PATH), + eq(null), + any(), + ) + ).thenReturn( + resolution( + resolvedEndpoint(MethodId.Bolt11, SERVER_PRIVATE_BOLT11), + version = 7uL, + linkState = null, + ), + ) + + val result = sut.beginPaymentRequest(request).getOrThrow() + + assertEquals(PublicPaykitPaymentResult.NoEndpoint, result) + verify(coreService, never()).decode(any()) + verifyBlocking(publicPaykitRepo, never()) { beginPayment(any()) } + } + @Test fun `beginPaymentRequest keeps typed recovery failures pending`() = test { val request = paymentRequest() - whenever { + whenever( paykitSdkService.prepareAndResolvePrivateContactPayment( eq(CONTACT_KEY), eq(SERVER_RECEIVER_PATH), eq(null), any(), ) - }.doSuspendableAnswer { + ).doSuspendableAnswer { throw PaykitException.RecoveryRequired("recovery_required", "Handshake is in progress") } diff --git a/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt b/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt index ed3e535969..c65284712b 100644 --- a/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt @@ -30,6 +30,7 @@ class PaymentRequestPresentationTest { request.amountPrefix(isOutgoingPayment = false, showSignedAmount = request.hasPaymentEvidence), ) assertEquals("", request.detailsAmountPrefix()) + assertEquals("2,500 sats", request.detailsAmountText("2,500 sats")) } } @@ -44,8 +45,10 @@ class PaymentRequestPresentationTest { assertTrue(incoming.hasPaymentEvidence) assertEquals("-", incoming.amountPrefix(isOutgoingPayment = false, showSignedAmount = true)) assertEquals("-", incoming.detailsAmountPrefix()) + assertEquals("- 2,500 sats", incoming.detailsAmountText("2,500 sats")) assertEquals("+", outgoing.amountPrefix(isOutgoingPayment = false, showSignedAmount = true)) assertEquals("+", outgoing.detailsAmountPrefix()) + assertEquals("+ 2,500 sats", outgoing.detailsAmountText("2,500 sats")) } @Test diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 2bddd053f7..09515de81a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -969,11 +969,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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.slashtags__error_pay_waiting_msg)).thenReturn("Try again") whenever(privatePaykitRepo.beginPaymentRequest(request)).thenReturn( Result.success(PublicPaykitPaymentResult.PrivateLinkPending) ) - whenever { paykitPaymentRequestRepo.dismiss(request) }.thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.dismiss(request)).thenReturn(Result.success(Unit)) pendingPaykitPaymentRequests.value = listOf(request) surfacedPaykitPaymentRequestIds += request.id enablePaykitUi() @@ -1000,6 +1000,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { request.counterparty, IncomingPaykitPaymentRequestFailureReason.PaymentDetailsPending, ) + verify(toastManager, times(2)).enqueue(check { assertEquals("Try again", it.description) }) } @Test @@ -6578,7 +6579,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) .thenReturn(Result.success(Unit)) - whenever { lightningRepo.fetchLnurlInvoice(lnurl, lnurl.callbackAmountMsats(request.amountSats), null) } + whenever(lightningRepo.fetchLnurlInvoice(lnurl, lnurl.callbackAmountMsats(request.amountSats), null)) .thenReturn(Result.failure(IllegalStateException("callback failed"))) sut.showSheet(Sheet.Send(SendRoute.Confirm)) advanceUntilIdle() @@ -6607,7 +6608,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { pendingPaykitPaymentRequests.value = emptyList() stubOpenedPaymentRequest(request, lnurl.uri) - whenever { coreService.decode(lnurl.uri) }.thenReturn(Scanner.LnurlPay(lnurl)) + whenever(coreService.decode(lnurl.uri)).thenReturn(Scanner.LnurlPay(lnurl)) whenever(paykitPaymentRequestRepo.refresh()).doSuspendableAnswer { pendingPaykitPaymentRequests.value = listOf(request) Result.success(Unit) @@ -6708,6 +6709,65 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) } + @Test + fun `hardware payment request releases private details when acceptance fails`() = test { + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + whenever(paykitPaymentRequestRepo.accept(request)) + .thenReturn(Result.failure(IllegalStateException("accept failed"))) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = "bcrt1qpaymentrequest", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + hardwareWalletId = "hardware-wallet", + ) + ) + + assertFalse(sut.prepareHardwareContactPayment()) + + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentProofRepo).cancelPreparation(request) + verify(paykitPaymentProofRepo, never()).markOnchainPaymentStarted(any(), any(), any()) + } + + @Test + fun `hardware payment request releases private details when proof start fails`() = test { + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + whenever(paykitPaymentRequestRepo.accept(request)).thenReturn(Result.success(Unit)) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + whenever( + paykitPaymentProofRepo.markOnchainPaymentStarted( + request, + "bcrt1qpaymentrequest", + "hardware-wallet", + ) + ).thenReturn(Result.failure(IllegalStateException("proof start failed"))) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = "bcrt1qpaymentrequest", + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + hardwareWalletId = "hardware-wallet", + ) + ) + + assertFalse(sut.prepareHardwareContactPayment()) + + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentProofRepo).cancelPreparation(request) + } + @Test fun `approved hardware payment request preparation is idempotent`() = test { val request = paymentRequest() @@ -7411,6 +7471,46 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) } + @Test + fun `incoming payment request releases private details when acceptance fails`() = test { + val address = "bcrt1qpaymentrequest" + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(privatePaykitRepo.consumePrivatePaymentList(testPublicKey, privateContext)) + .thenReturn(Result.success(Unit)) + whenever(paykitPaymentRequestRepo.accept(request)) + .thenReturn(Result.failure(IllegalStateException("accept failed"))) + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = address, + amount = request.amountSats, + payMethod = SendMethod.ONCHAIN, + speed = TransactionSpeed.Medium, + isPaymentRequest = true, + ), + ) + + confirmCurrentPayment() + + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentProofRepo).cancelPreparation(request) + verify(lightningRepo, never()).sendOnChain( + address = any(), + sats = any(), + speed = anyOrNull(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = any(), + tags = any(), + beforeSendAttempt = any(), + onBroadcast = any(), + ) + } + @Test fun `incoming payment request rejects mismatched fixed invoice amount before acceptance`() = test { val request = paymentRequest() diff --git a/journeys/README.md b/journeys/README.md index 507d6f3301..8c24ba27d6 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -210,6 +210,7 @@ and Settings (`Tab-general`, `Tab-security`, `Tab-advanced`, `NavigationBack`, ` | Home suggestion cards | `Suggestion-` | — *(cards expose no identifier)* | | Receive QR copy button | `ReceiveCopyQR` | `ReceiveCopyQR` *(absent from `snapshot-ui` targets; see below)* | | Payment Request row | `PaymentRequestRow-` | `PaymentRequestRow--` *(`-one-time` for a one-off)* | +| Payment Request detail screen | `PaymentRequestDetailsScreen` | `PaymentRequestDetailScreen` | Two of those are unreconciled rather than intentional: the Send screen emitting both `AvailableAmount` and `available_balance`, and the background-payments row name. Settling either is a diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index d7023bfc19..50bf5d5904 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -35,6 +35,8 @@ That run established the issuer shapes captured by the fixture: lowercase `btc`, - Pending-request bell: `PaymentRequestsBell` - Incoming sheet: `PaymentRequestsSheet` +- Detail screen: `PaymentRequestDetailsScreen` +- Detail amount and status: `PaymentRequestDetailsAmount`, `PaymentRequestDetailsStatus` - Request row: `PaymentRequestRow-` - Pay action: `PaymentRequestPay-` - Dismiss action: `PaymentRequestDismiss-` diff --git a/journeys/payment-requests/issuer-interoperability.xml b/journeys/payment-requests/issuer-interoperability.xml index c90113a0b5..bfe6b3ad8a 100644 --- a/journeys/payment-requests/issuer-interoperability.xml +++ b/journeys/payment-requests/issuer-interoperability.xml @@ -10,7 +10,12 @@ Press Android back to dismiss the automatic confirmation Verify the pending Payment Requests bell (testTag "PaymentRequestsBell") is visible, then tap it Verify the incoming Payment Requests sheet (testTag "PaymentRequestsSheet") appears - Verify request row (testTag "PaymentRequestRow-71300000-0000-4000-8000-000000000001") shows 100,000 sats + Verify the one-time request row (testTag "PaymentRequestRow-71300000-0000-4000-8000-000000000001") shows 100,000 sats + Tap the one-time request row (testTag "PaymentRequestRow-71300000-0000-4000-8000-000000000001") + Verify Payment Request details (testTag "PaymentRequestDetailsScreen") shows an unsigned 100,000 sats amount (testTag "PaymentRequestDetailsAmount") and status "Waiting for payment" (testTag "PaymentRequestDetailsStatus") + Tap Back (testTag "NavigationBack") + Tap the pending Payment Requests bell (testTag "PaymentRequestsBell") + Verify the incoming Payment Requests sheet (testTag "PaymentRequestsSheet") appears Tap Pay (testTag "PaymentRequestPay-71300000-0000-4000-8000-000000000001") Verify the Payment Request confirmation screen (testTag "PaymentRequestConfirm") shows 100,000 sats Tap Show details (testTag "SendConfirmToggleDetails")