diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt index 47e7b56733..de6f261ce4 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestsScreenTest.kt @@ -164,6 +164,35 @@ class PaymentRequestsScreenTest { composeTestRule.onNodeWithTag("PaymentRequestCreate").assertIsDisplayed() } + @Test + fun unpaidHistoryKeepsPrimaryFiatAmountAndShowsLifecycleStatus() { + val accepted = request(id = "accepted").copy( + createdAt = Clock.System.now(), + lifecycleState = PaymentRequestLifecycleState.ACCEPTED, + ) + + composeTestRule.setContent { + PaymentRequestsTestSurface(CurrencyState(primaryDisplay = PrimaryDisplay.FIAT)) { + PaymentRequestsContent( + requests = persistentListOf(accepted), + pending = persistentListOf(), + contacts = persistentListOf(), + subscriptions = persistentListOf(), + dismissingRequestIds = persistentSetOf(), + canRequestPayment = true, + onBack = {}, + onRequestPayment = {}, + onPay = {}, + onDismiss = { Result.success(Unit) }, + onDetails = {}, + ) + } + } + + composeTestRule.onNodeWithTag("MoneyPrimary").assertTextContains("$", substring = true) + composeTestRule.onNodeWithText("Accepted").assertIsDisplayed() + } + @Test fun outgoingRequestShowsPendingOnFiatLineWhenBitcoinIsPrimary() { setOutgoingRequestContent(PrimaryDisplay.BITCOIN) 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 f59a22e68e..e2bd190f8b 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 @@ -414,6 +415,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, @@ -556,20 +580,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, @@ -665,16 +701,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", @@ -690,6 +721,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 3cf4345e1d..be85e47904 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 @@ -243,7 +243,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..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 @@ -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 @@ -153,12 +152,22 @@ 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() - 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 +301,24 @@ private fun PaymentRequestTags( } } -private fun PaykitPaymentRequest.detailsAmountPrefix(): String = - if (direction == PaykitPaymentRequestDirection.Incoming) "-" else "+" +internal fun PaykitPaymentRequest.detailsAmountPrefix(): String = when { + !hasPaymentEvidence -> "" + direction == PaykitPaymentRequestDirection.Incoming -> "-" + else -> "+" +} + +internal fun PaykitPaymentRequest.detailsAmountText(amount: String): String = + "${detailsAmountPrefix()} $amount".trimStart() @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..b5a744f931 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 @@ -329,10 +330,14 @@ internal fun PaymentRequestsContent( PaymentRequestCard( request = request, contact = contacts.contactFor(request), - compactSubtitle = subscriptions.nameFor(request) - ?: request.note?.takeIf(String::isNotBlank) - ?: paymentRequestDate(request), - showSignedAmount = true, + compactSubtitle = if (request.hasPaymentEvidence) { + subscriptions.nameFor(request) + ?: request.note?.takeIf(String::isNotBlank) + ?: paymentRequestDate(request) + } else { + paymentRequestStatus(request) + }, + showSignedAmount = request.hasPaymentEvidence, onClick = { onDetails(request.id) }, ) } @@ -472,21 +477,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 +503,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 +644,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 +674,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/ui/screens/wallets/send/SendErrorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt index 1032cea971..d65d911fd6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendErrorScreen.kt @@ -64,6 +64,7 @@ private fun Content( .fillMaxSize() .gradientBackground() .navigationBarsPadding() + .testTag("SendFailure") ) { SheetTopBar(title) diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 0ed710ef41..00502b0d45 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -540,9 +540,14 @@ fun SendSheet( resetResult .onSuccess { - appViewModel.setSendEvent(SendEvent.ClearPayConfirmation) - navController.navigateTo(route.retryRoute.sendRoute) { - popUpTo(navController.graph.id) { inclusive = true } + val requestId = sendUiState.incomingPaymentRequestId + if (sendUiState.isPaymentRequest && requestId != null) { + appViewModel.retryIncomingPaymentRequest(requestId) + } else { + appViewModel.setSendEvent(SendEvent.ClearPayConfirmation) + navController.navigateTo(route.retryRoute.sendRoute) { + popUpTo(navController.graph.id) { inclusive = true } + } } } .onFailure { appViewModel.toast(it) } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index a66e2fe255..4538977830 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -1098,11 +1098,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 } @@ -1117,6 +1121,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.slashtags__error_pay_waiting_msg), + ) + if (restorePaymentRequestSheet && currentSheet.value == null) showSheet(Sheet.PaymentRequests) + } + private fun isCurrentPaymentRequestPresentation(request: PaykitPaymentRequest, generation: Long): Boolean = activePaymentRequestPresentationGeneration == generation && paymentRequestPresentationGeneration == generation && @@ -4013,6 +4038,7 @@ class AppViewModel @Inject constructor( } acceptIncomingPaymentRequestIfNeeded(contactPaymentContext).onFailure { + releasePrivatePaymentListIfNeeded(contactPaymentContext) cancelPaymentProofPreparation(preparedPaymentProofRequest) handlePaymentPreparationFailure(it, contactPaymentContext) return @@ -4034,9 +4060,22 @@ class AppViewModel @Inject constructor( it.copy(decodedInvoice = invoice) } }.onFailure { + releasePrivatePaymentListIfNeeded(contactPaymentContext) cancelPaymentProofPreparation(preparedPaymentProofRequest) val message = getLnurlInvoiceFetchErrorMessage(it) - handlePaymentPreparationFailure(AppError(message, it), contactPaymentContext) + val error = AppError(message, it) + if (incomingPaymentRequest != null) { + setSendEffect( + SendEffect.NavigateToError( + error.toSendFailureDetails( + context, + _sendUiState.value.currentLightningPaymentRequest(), + ), + ), + ) + } else { + handlePaymentPreparationFailure(error, contactPaymentContext) + } return } } @@ -4052,6 +4091,7 @@ class AppViewModel @Inject constructor( SendMethod.LIGHTNING -> proceedWithLightningPayment( incomingPaymentRequest, preparedPaymentProofRequest, + contactPaymentContext, amount, ) } @@ -4066,7 +4106,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, @@ -4074,8 +4115,9 @@ class AppViewModel @Inject constructor( beforeSendAttempt = { if (preparedPaymentProofRequest != null) { markOnchainPaymentStarted(incomingPaymentRequest, address).getOrThrow() - onchainPaymentStarted = true + paymentProofStarted = true } + sendAttempted = true }, onBroadcast = { txId -> proofRequest = null @@ -4098,7 +4140,8 @@ class AppViewModel @Inject constructor( }.onFailure { error -> handleOnchainPaymentFailure( error = error, - paymentStarted = onchainPaymentStarted, + sendAttempted = sendAttempted, + paymentProofStarted = paymentProofStarted, incomingPaymentRequest = incomingPaymentRequest, preparedPaymentProofRequest = proofRequest, contactPaymentContext = contactPaymentContext, @@ -4108,31 +4151,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) { @@ -4147,9 +4196,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) @@ -4202,6 +4253,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) } @@ -4248,6 +4300,7 @@ class AppViewModel @Inject constructor( return false } acceptIncomingPaymentRequestIfNeeded(contactPaymentContext).onFailure { + releasePrivatePaymentListIfNeeded(contactPaymentContext) handlePaymentPreparationFailure(it, contactPaymentContext) return false } @@ -5175,6 +5228,7 @@ class AppViewModel @Inject constructor( synchronized(contactPaymentContextLock) { if (preparedContactPaymentContext == contactPaymentContext) preparedContactPaymentContext = null } + releasePrivatePaymentListIfNeeded(contactPaymentContext) cancelPaymentProofPreparation(preparedPaymentProofRequest) handlePaymentPreparationFailure(it, contactPaymentContext) return false @@ -5269,6 +5323,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 7a2ff36a4e..543d6b622d 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 @@ -65,6 +66,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime @@ -1291,7 +1293,91 @@ class PrivatePaykitRepoTest : BaseUnitTest(StandardTestDispatcher()) { val result = sut.beginSavedContactPayment(CONTACT_KEY).getOrThrow() + 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 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( + 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()) } } @@ -1371,6 +1457,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/contacts/ContactDetailViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt index b543bdb7b4..585e559f59 100644 --- a/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/contacts/ContactDetailViewModelTest.kt @@ -611,6 +611,30 @@ class ContactDetailViewModelTest : BaseUnitTest() { } } + @Test + fun `pending private link closes request or pay sheet without opening payment`() = test { + whenever(context.getString(any())).thenReturn("") + whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(listOf(createContact()))) + whenever(paykitPaymentRequestRepo.refreshEligibleTarget(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(targetCheck(target))) + whenever(privatePaykitRepo.beginSavedContactPayment(TEST_PUBLIC_KEY)) + .thenReturn(Result.success(PublicPaykitPaymentResult.PrivateLinkPending)) + eligibleTargets.value = listOf(target) + val sut = createSut() + advanceUntilIdle() + sut.onClickPay() + advanceUntilIdle() + + sut.effects.test { + sut.payContact() + advanceUntilIdle() + + assertFalse(sut.uiState.value.isPayLoading) + assertFalse(sut.uiState.value.showRequestOrPaySheet) + expectNoEvents() + } + } + @Test fun `unsaved contact skips the payment request check`() = test { whenever(pubkyRepo.contacts).thenReturn(MutableStateFlow(emptyList())) 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..c65284712b --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/paymentrequests/PaymentRequestPresentationTest.kt @@ -0,0 +1,85 @@ +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()) + assertEquals("2,500 sats", request.detailsAmountText("2,500 sats")) + } + } + + @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("- 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 + 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 8e06bb881b..09515de81a 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -90,6 +90,7 @@ import to.bitkit.data.keychain.Keychain import to.bitkit.domain.commands.NotifyChannelReadyHandler import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler +import to.bitkit.ext.callbackAmountMsats import to.bitkit.ext.fromHex import to.bitkit.ext.toSendFailureDetails import to.bitkit.models.BalanceState @@ -420,6 +421,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 { @@ -961,6 +964,45 @@ 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.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)) + 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, + ) + verify(toastManager, times(2)).enqueue(check { assertEquals("Try again", it.description) }) + } + @Test fun `unaffordable request releases manual presentation state`() = test { val request = paymentRequest() @@ -6302,6 +6344,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) } @@ -6348,6 +6391,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() @@ -6428,6 +6507,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } verify(paykitPaymentProofRepo, never()).failLightningPayment(any()) verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test @@ -6473,10 +6553,76 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) verify(lightningRepo).payInvoice(bolt11 = bolt11, sats = null) verify(paykitPaymentProofRepo).failLightningPayment(invoicePaymentHash, error) + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).cancelPreparation(request) } } + @Test + fun `failed LNURL request callback releases preparation and retry reopens request`() = test { + val request = paymentRequest() + val privateContext = PrivatePaykitPaymentContext("bitkit/server", 7uL) + val lnurl = LnurlPayData( + uri = "lnurl1failedrequest", + callback = "https://example.com/callback", + minSendable = 1_000uL, + maxSendable = 100_000_000uL, + metadataStr = "[]", + commentAllowed = null, + allowsNostr = false, + nostrPubkey = null, + ) + enablePaykitUi() + pubkyPublicKey.value = testPublicKey + balanceState.value = BalanceState(maxSendLightningSats = 100_000u) + whenever(context.getString(R.string.wallet__error_lnurl_invoice_fetch)).thenReturn("Invoice unavailable") + 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)) + .thenReturn(Result.failure(IllegalStateException("callback failed"))) + sut.showSheet(Sheet.Send(SendRoute.Confirm)) + advanceUntilIdle() + setActiveContactPaymentContext(testPublicKey, privateContext, request) + setSendState( + SendUiState( + address = lnurl.uri, + amount = request.amountSats, + payMethod = SendMethod.LIGHTNING, + lnurl = LnurlParams.LnurlPay(lnurl), + isPaymentRequest = true, + incomingPaymentRequestId = request.id, + ), + ) + + sut.sendEffect.test { + sut.setSendEvent(SendEvent.PayConfirmed) + advanceUntilIdle() + + assertTrue(awaitItem() is SendEffect.NavigateToError) + } + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) + verify(paykitPaymentProofRepo).cancelPreparation(request) + verify(lightningRepo, never()).payInvoice(any(), anyOrNull()) + verify(toastManager, never()).enqueue(any()) + + pendingPaykitPaymentRequests.value = emptyList() + stubOpenedPaymentRequest(request, lnurl.uri) + whenever(coreService.decode(lnurl.uri)).thenReturn(Scanner.LnurlPay(lnurl)) + whenever(paykitPaymentRequestRepo.refresh()).doSuspendableAnswer { + pendingPaykitPaymentRequests.value = listOf(request) + Result.success(Unit) + } + + sut.retryIncomingPaymentRequest(request.id) + advanceUntilIdle() + + verify(paykitPaymentRequestRepo).refresh() + verify(privatePaykitRepo, atLeast(1)).beginPaymentRequest(request) + assertEquals(request.id, sut.sendUiState.value.incomingPaymentRequestId) + assertTrue(sut.currentSheet.value is Sheet.Send) + } + @Test fun `uncertain incoming lightning submission opens pending without clearing proof`() = test { val request = paymentRequest() @@ -6514,6 +6660,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 @@ -6562,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() @@ -6620,6 +6826,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() verify(paykitPaymentProofRepo).failOnchainPayment(request) + verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test @@ -6648,6 +6855,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() verify(paykitPaymentProofRepo).failOnchainPayment(request) + verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test @@ -6712,6 +6920,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { finishCompletion.complete(Unit) advanceUntilIdle() verify(paykitPaymentProofRepo).completeOnchainPayment(request, "txid", MethodId.P2wpkh.rawValue) + verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test @@ -6945,21 +7154,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", @@ -6976,21 +7209,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, @@ -6998,6 +7236,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) setActiveContactPaymentContext( testPublicKey, + privateContext, incomingPaymentRequest = request, isInitialSubscriptionPayment = true, ) @@ -7017,6 +7256,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertTrue(awaitItem() is SendEffect.NavigateToError) } + verify(privatePaykitRepo).releasePrivatePaymentList(testPublicKey, privateContext) verify(paykitPaymentProofRepo).failOnchainPayment(request) } } @@ -7024,20 +7264,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", @@ -7080,6 +7319,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } verify(paykitPaymentProofRepo, never()).failOnchainPayment(any()) verify(paykitPaymentProofRepo, never()).cancelPreparation(any()) + verify(privatePaykitRepo, never()).releasePrivatePaymentList(any(), any()) } @Test @@ -7165,15 +7405,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", @@ -7188,6 +7431,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 @@ -7227,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/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 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 3f90f421fe..50bf5d5904 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -22,6 +22,8 @@ Rejected fixture shapes stay in unit tests because Bitkit intentionally does not `contact-request-or-pay.xml` uses the same two-instance setup and starts from the payer's Contact Detail screen, opened through the `bitkit://contact` deeplink. Its timing step assumes the payer has been running for about a minute: right after launch, the Paykit session restore and link refresh hold the SDK and can push the Pay step well past the budget. +`definite-pre-broadcast-retry.xml` uses the linked fixture issuer and the local regtest LNURL server. Configure its LNURL-pay metadata endpoint normally, but make its invoice callback fail the first request and succeed after it is switched back to the healthy response. Do not republish the Paykit payment list between attempts. This makes the first send fail before Lightning dispatch and proves that the same private payment details can be opened and paid on retry. + ## 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 @@ -33,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-` @@ -41,6 +45,8 @@ That run established the issuer shapes captured by the fixture: lowercase `btc`, - Confirmation invoice note: `PaymentRequestInvoiceNote` - Confirmation details: `SendConfirmToggleDetails` - Saved-contact recipient: `ReviewContactRecipient` +- Send failure: `SendFailure` and retry action `Retry` +- Swipe confirmation control: `GRAB` - Contact Detail pay action: `ContactPay` - Request or Pay sheet: `RequestOrPaySheet` (its Pay and Request buttons carry no tag; find them by text) - Payment Request amount screen: `PaymentRequestAmount` @@ -53,9 +59,10 @@ That run established the issuer shapes captured by the fixture: lowercase `btc`, Bitkit keeps their lifecycle and paid-period history, and subscription cancellation, but does not accept them, offer payments, or schedule payment reminders. The journey requires a controlled rc56 peer to prepare the accepted and paid records; repository -tests cover these states without sending funds. On both platforms, payment-history rows -show notes or dates rather than lifecycle labels, and active subscriptions are opened -from Overview. The journeys therefore record each fixture's payment request id, check -its full row identifier, and include the required back and tab transitions. The accepted +tests cover these states without sending funds. On Android, unpaid history rows show +lifecycle labels, while paid rows show subscription names, notes, or dates. Active +subscriptions are opened from Overview. The journeys record each fixture's payment +request id, check its full row identifier, and include the required back and tab +transitions. The accepted subscription must have no end date so cancellation is available. The proposal review must explain that its payment details are unsupported and offer no Subscribe control. diff --git a/journeys/payment-requests/definite-pre-broadcast-retry.xml b/journeys/payment-requests/definite-pre-broadcast-retry.xml new file mode 100644 index 0000000000..c59c7d0f13 --- /dev/null +++ b/journeys/payment-requests/definite-pre-broadcast-retry.xml @@ -0,0 +1,20 @@ + + + Verifies that a private Payment Request remains payable after its first send fails before + Lightning dispatch. Requires the linked fixture issuer, a funded regtest Lightning wallet, and + the local LNURL server configured as described in README.md. Keep the same Paykit payment list + published for both attempts. + + + Launch the E2E Bitkit app with Paykit UI enabled, the fixture issuer saved and linked, and the LNURL-pay invoice callback configured to fail + Have the issuer publish the failing LNURL-pay endpoint under identifier "btc-lightning-lnurl" and send a proposed one-time Payment Request for 21,000 sats + Verify the LNURL payment review shows 21,000 sats (testTag "ReviewAmount-primary" or "ReviewAmount-secondary", depending on the primary display setting) + Swipe the send control (testTag "GRAB") + Verify the send failure screen (testTag "SendFailure") appears + Configure the same LNURL-pay invoice callback to succeed without publishing a new Paykit payment list + Tap Try Again (testTag "Retry") + Verify the LNURL payment review appears again with 21,000 sats (testTag "ReviewAmount-primary" or "ReviewAmount-secondary", depending on the primary display setting) + Swipe the send control (testTag "GRAB") + Verify the payment success screen (testTag "SendSuccess") appears + + 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") diff --git a/journeys/payment-requests/payment-deadline-history.xml b/journeys/payment-requests/payment-deadline-history.xml index abe0dba6f3..1ba8b15b3d 100644 --- a/journeys/payment-requests/payment-deadline-history.xml +++ b/journeys/payment-requests/payment-deadline-history.xml @@ -6,8 +6,9 @@ plus an incoming, accepted monthly BTC subscription with no end date, a period-start deadline, one paid period and one unpaid period. Acceptance and proof submission must be prepared through the controlled client because Bitkit intentionally cannot accept these terms. Use valid regtest payment proofs. - Do not reset or replace a funded wallet to prepare this fixture. Record the payment request id - for each one-time state because history rows show notes or dates instead of status labels. + Do not reset or replace a funded wallet to prepare this fixture. On Android, unpaid + history rows show status labels, while paid rows show subscription names, notes or dates. + Record each payment request id. Open Subscriptions and select the Payments tab after the controlled peer's records have synchronized