From 1568abb7416a8c0d80954e661d9723ceea2d7509 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 01:23:05 +0200 Subject: [PATCH 1/4] fix: keep fixed paykit requests on confirmation with an asset switch --- .../screens/wallets/send/SendConfirmScreen.kt | 8 +- .../screens/wallets/usdt/PaykitUsdtReview.kt | 13 ++- .../screens/wallets/usdt/UsdtSendContent.kt | 47 +++++++++-- .../ui/screens/wallets/usdt/UsdtViewModel.kt | 8 ++ .../java/to/bitkit/ui/sheets/SendSheet.kt | 14 +++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 78 ++++++++++++++++-- app/src/main/res/values/strings.xml | 1 + .../screens/wallets/usdt/UsdtViewModelTest.kt | 35 ++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 81 +++++++++++++++++-- changelog.d/next/1623.fixed.md | 1 + 10 files changed, 259 insertions(+), 27 deletions(-) create mode 100644 changelog.d/next/1623.fixed.md diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt index 9d9f3d6d11..cfda9d0b91 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendConfirmScreen.kt @@ -592,10 +592,10 @@ private fun OnChainDetails( stringResource(R.string.wallet__savings__title) }, color = if (uiState.hardwareWalletId != null) Colors.Blue else Colors.Brand, - enabled = uiState.canSwitchBitcoinFundingSource && interactionsEnabled, + enabled = uiState.canSwitchConfirmFundingSource && interactionsEnabled, isLoading = uiState.isFundingSourceLoading, clickable = interactionsEnabled && !uiState.isRetainedHardwarePayment, - icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchBitcoinFundingSource }, + icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchConfirmFundingSource }, onClick = { onEvent(SendEvent.PaymentMethodSwitch) }, modifier = Modifier.testTag("SendConfirmAssetButton") ) @@ -739,9 +739,9 @@ private fun LightningDetails( NumberPadActionButton( text = stringResource(R.string.wallet__spending__title), color = Colors.Purple, - enabled = uiState.canSwitchBitcoinFundingSource && interactionsEnabled, + enabled = uiState.canSwitchConfirmFundingSource && interactionsEnabled, isLoading = uiState.isFundingSourceLoading, - icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchBitcoinFundingSource }, + icon = R.drawable.ic_transfer.takeIf { uiState.canSwitchConfirmFundingSource }, onClick = { onEvent(SendEvent.PaymentMethodSwitch) }, modifier = Modifier.testTag("SendConfirmAssetButton") ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt index fc5f533ded..f65356de53 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt @@ -28,6 +28,7 @@ import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.theme.Colors import to.bitkit.viewmodels.AppViewModel +import to.bitkit.viewmodels.SendEvent import to.bitkit.viewmodels.SendUiState import kotlin.time.Duration.Companion.seconds @@ -37,6 +38,7 @@ fun PaykitUsdtReview( app: AppViewModel, onBack: () -> Unit, onDone: () -> Unit, + onEditAmount: () -> Unit = onBack, viewModel: UsdtViewModel = hiltViewModel() ) { val wallet by viewModel.wallet.collectAsStateWithLifecycle() @@ -66,6 +68,11 @@ fun PaykitUsdtReview( viewModel.quote(requireNotNull(uiState.usdtRecipient), uiState.paykitAmount, UsdtDestination.ARBITRUM) } } + LaunchedEffect(state.quote, state.error) { + if (state.quote == null && state.error == R.string.usdt__error_expired && uiState.isPaymentRequest) { + viewModel.requote(requireNotNull(uiState.usdtRecipient), uiState.paykitAmount, UsdtDestination.ARBITRUM) + } + } LaunchedEffect(state.submitted, lifecycle) { if (state.submitted) { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -106,7 +113,9 @@ fun PaykitUsdtReview( onRecipientChange = {}, onAmountChange = {}, onRecipientContinue = {}, - onPageChange = { back() }, + onPageChange = { + if (!state.busy && !state.authenticationRequired && !state.submitted) onEditAmount() + }, onBack = back, onReview = { viewModel.quote( @@ -125,6 +134,8 @@ fun PaykitUsdtReview( isPaymentRequest = uiState.isPaymentRequest, paymentRequestNote = uiState.paymentRequestNote, contact = uiState.contactPaymentProfile, + onSwitchAsset = { app.setSendEvent(SendEvent.PaymentMethodSwitch) } + .takeIf { uiState.canSwitchConfirmFundingSource }, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSendContent.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSendContent.kt index ed5c1a4bfb..f8e02c56ad 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSendContent.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtSendContent.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -126,6 +127,7 @@ internal fun UsdtPaymentContent( contact: PubkyProfile? = null, onDestinationChange: ((UsdtDestination) -> Unit)? = null, destinations: ImmutableList = Env.usdtDestinations.toImmutableList(), + onSwitchAsset: (() -> Unit)? = null, ) { if (page == UsdtPage.WALLET) return @@ -160,6 +162,9 @@ internal fun UsdtPaymentContent( } } val showSubmitted = pending || (completed && hasShownSubmitted) + // A payment request's amount is fixed, so its review waits for the quote on a confirm screen, not on Amount. + val preparingConfirmation = quote == null && page == UsdtPage.AMOUNT && isPaymentRequest && !amountEditable && + state.error == null if (showSuccess && completed && quote != null) { NewTransactionSheetView( details = NewTransactionSheetDetails( @@ -184,7 +189,7 @@ internal fun UsdtPaymentContent( showSubmitted && failed -> R.string.wallet__send_error_tx_failed showSubmitted && state.error != null -> R.string.wallet__payment_request_expired showSubmitted -> R.string.usdt__submitted - quote != null && isPaymentRequest -> R.string.wallet__payment_request + (quote != null || preparingConfirmation) && isPaymentRequest -> R.string.wallet__payment_request quote != null -> R.string.wallet__send_review page == UsdtPage.AMOUNT -> R.string.usdt__amount else -> R.string.usdt__send_title @@ -213,7 +218,9 @@ internal fun UsdtPaymentContent( onConfirm, completed, onEditAmount = { onPageChange(UsdtPage.AMOUNT) }, + onSwitchAsset = onSwitchAsset, ) + preparingConfirmation -> UsdtConfirmationLoading(amount, contact, paymentRequestNote) page == UsdtPage.AMOUNT -> UsdtAmountContent( amount, destination, @@ -540,6 +547,7 @@ private fun UsdtConfirmation( onConfirm: () -> Unit, completed: Boolean, onEditAmount: () -> Unit, + onSwitchAsset: (() -> Unit)?, ) { var showDetails by rememberSaveable(quote.id) { mutableStateOf(false) } val swipeProgress = remember { mutableFloatStateOf(0f) } @@ -558,6 +566,7 @@ private fun UsdtConfirmation( note, isPaymentRequest, showDetails, + onSwitchAsset = onSwitchAsset?.takeUnless { state.busy || completed }, ) { if (!state.busy && !completed) onEditAmount() } @@ -595,6 +604,31 @@ private fun UsdtConfirmation( } } +@Composable +private fun UsdtConfirmationLoading(amount: String, contact: PubkyProfile?, note: String?) { + Column(modifier = Modifier.fillMaxSize().testTag("UsdtConfirmLoading")) { + UsdtAmountHeader(amount.ifEmpty { "0" }) + VerticalSpacer(24.dp) + PaymentRequestSummary( + profile = contact, + note = note?.trim()?.takeIf { it.isNotEmpty() }, + accentColor = Colors.Usdt, + ) + Spacer(modifier = Modifier.weight(1f)) + SwipeToConfirm( + text = stringResource(R.string.wallet__send_swipe), + color = Colors.Usdt, + onConfirm = {}, + enabled = false, + loading = true, + confirmed = false, + progress = remember { mutableFloatStateOf(0f) }, + textStyle = AppTextStyles.BodySSB.copy(letterSpacing = 0.sp), + modifier = Modifier.fillMaxWidth(), + ) + } +} + @Composable private fun ColumnScope.UsdtReviewDetailsButton(showDetails: Boolean, onClick: () -> Unit) { PrimaryButton( @@ -642,6 +676,7 @@ private fun UsdtReviewHeader( isPaymentRequest: Boolean, showDetails: Boolean, modifier: Modifier = Modifier, + onSwitchAsset: (() -> Unit)? = null, onEditAmount: () -> Unit, ) { Column(modifier = modifier) { @@ -652,7 +687,7 @@ private fun UsdtReviewHeader( ) VerticalSpacer(24.dp) if (showDetails) { - UsdtQuoteDetails(quote, contact) + UsdtQuoteDetails(quote, contact, onSwitchAsset) note?.let { VerticalSpacer(16.dp) PaymentRequestInvoiceNote(it) @@ -665,16 +700,18 @@ private fun UsdtReviewHeader( } @Composable -private fun UsdtQuoteDetails(quote: UsdtQuote, contact: PubkyProfile?) { +private fun UsdtQuoteDetails(quote: UsdtQuote, contact: PubkyProfile?, onSwitchAsset: (() -> Unit)?) { var showFullAddress by rememberSaveable(quote.recipient) { mutableStateOf(false) } Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { UsdtDetailField(caption = stringResource(R.string.usdt__pay_from), modifier = Modifier.weight(1f)) { NumberPadActionButton( text = "USDT", color = Colors.Usdt, - enabled = false, + enabled = onSwitchAsset != null, + icon = R.drawable.ic_transfer.takeIf { onSwitchAsset != null }, textStyle = AppTextStyles.CaptionM.copy(letterSpacing = 1.sp), - onClick = {}, + onClick = { onSwitchAsset?.invoke() }, + modifier = Modifier.testTag("UsdtPayFrom"), ) } UsdtDetailCell( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt index 289ef46d6e..c0f24028c3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt @@ -246,6 +246,14 @@ class UsdtViewModel @Inject constructor( fun cancelAuthentication() = _state.update { it.copy(authenticationRequired = false) } fun edit() = _state.update { UsdtSendState(refreshError = it.refreshError) } + /** Replaces an expired quote and keeps the review, telling the user to check the new fee and swipe again. */ + fun requote(recipient: String, amount: String, destination: UsdtDestination) = viewModelScope.launch { + if (_state.value.error != R.string.usdt__error_expired) return@launch + _state.update { it.copy(error = null) } + quote(recipient, amount, destination).join() + _state.update { if (it.quote != null) it.copy(error = R.string.usdt__error_quote_refreshed) else it } + } + private data class PreparedQuote(val quote: UsdtQuote, val amount: String, val sender: String) private fun send(quote: UsdtQuote) = viewModelScope.launch { 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 132d87ae4b..17edaf64e6 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -112,8 +112,8 @@ internal fun NavController.navigateToCoinSelection() { internal fun NavController.navigateToSendAmount(startDestination: SendRoute) { if (popBackStack(SendRoute.Amount, inclusive = false)) return navigateTo(SendRoute.Amount) { - if (startDestination == SendRoute.Confirm) { - popUpTo(SendRoute.Confirm) { inclusive = true } + if (startDestination == SendRoute.Confirm || startDestination == SendRoute.UsdtReview) { + popUpTo(startDestination) { inclusive = true } } } } @@ -269,6 +269,11 @@ fun SendSheet( is SendEffect.NavigateToCoinSelection -> navController.navigateToCoinSelection() is SendEffect.NavigateToConfirm -> navController.navigateTo(SendRoute.Confirm) is SendEffect.NavigateToUsdtReview -> navController.navigateTo(SendRoute.UsdtReview) + is SendEffect.ShowPaykitConfirmation -> { + if (!navController.popBackStack(it.route, inclusive = false)) { + navController.navigateTo(it.route) + } + } is SendEffect.NavigateToHardwareSign -> navController.navigateTo(SendRoute.HardwareSign) is SendEffect.PopBack -> navController.popBackStack(it.route, inclusive = false) is SendEffect.PaymentSuccess -> { @@ -349,7 +354,10 @@ fun SendSheet( PaykitUsdtReview( uiState = uiState, app = appViewModel, - onBack = { navController.popBackStack() }, + onBack = { + if (!navController.popBackStack()) appViewModel.hideSheet() + }, + onEditAmount = { navController.navigateToSendAmount(startDestination) }, onDone = { appViewModel.hideSheet() } ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 89c3f602d1..916db9221a 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3594,13 +3594,24 @@ class AppViewModel @Inject constructor( } refreshPaykitRequestAmount() updateCanSwitchWallet() - if (!openBitcoinRequestConfirmation(fromMainScanner)) { + if (!openPaykitRequestConfirmation(fromMainScanner)) { navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) } } - private suspend fun openBitcoinRequestConfirmation(fromMainScanner: Boolean): Boolean { - if (activeIncomingPaymentRequest() == null || _sendUiState.value.usdtRecipient != null) return false + /** + * A request's amount is fixed, so it opens on the confirm step of its preselected asset. + * Amount is the fallback when those terms cannot be bound. + */ + private suspend fun openPaykitRequestConfirmation(fromMainScanner: Boolean): Boolean { + val request = activeIncomingPaymentRequest() ?: return false + // Subscription periods keep reviewing the asset choice on Amount. + if (request.billingPeriod != null && _sendUiState.value.usdtRecipient != null) return false + if (_sendUiState.value.paykitUsesUsdt) { + if (!bindUsdtRequestTerms()) return false + navigateToSendRoute(fromMainScanner, SendRoute.UsdtReview, SendEffect.NavigateToUsdtReview) + return true + } if (!bindBitcoinRequestTerms()) return false navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm) refreshOnchainSendIfNeeded() @@ -3608,6 +3619,41 @@ class AppViewModel @Inject constructor( return true } + private suspend fun bindUsdtRequestTerms(): Boolean { + if (!_sendUiState.value.paykitUsesUsdt || !_sendUiState.value.isAmountInputValid) return false + paykitPaymentTerms = null + paykitReviewedAmount = null + reviewPaykitAmount(navigateToUsdtReview = false) + return paykitReviewedAmount != null + } + + /** Switching asset on a request's confirm step opens the other asset's confirm step in place of Amount. */ + private suspend fun switchPaykitRequestAsset(previous: SendUiState, wasReviewed: Boolean): Boolean { + val current = _sendUiState.value + if (!wasReviewed || activeIncomingPaymentRequest() == null || + previous.paykitUsesUsdt == current.paykitUsesUsdt + ) { + return false + } + paykitPaymentTerms = null + paykitReviewedAmount = null + val route = when { + current.paykitUsesUsdt -> SendRoute.UsdtReview.takeIf { bindUsdtRequestTerms() } + bindBitcoinRequestTerms() -> SendRoute.Confirm + else -> null + } + if (route == null) { + setSendEffect(SendEffect.NavigateToAmount) + return true + } + setSendEffect(SendEffect.ShowPaykitConfirmation(route)) + if (route == SendRoute.Confirm) { + refreshOnchainSendIfNeeded() + estimateLightningRoutingFeesIfNeeded() + } + return true + } + private suspend fun bindBitcoinRequestTerms(): Boolean { val state = _sendUiState.value if (state.paykitUsesUsdt) return false @@ -3751,6 +3797,7 @@ class AppViewModel @Inject constructor( if (current.isRetainedHardwarePayment) return val nextSource = nextFundingSource(current, requested) ?: return val canReviewInPlace = canRebindBitcoinRequestTerms(current, nextSource) + val wasReviewed = paykitReviewedAmount != null val generation = ++sendStateGeneration onchainSendRefreshJob?.cancel() val target = if (nextSource == SendFundingSource.Usdt) PaykitAsset.USDT else PaykitAsset.BTC @@ -3795,10 +3842,15 @@ class AppViewModel @Inject constructor( ) } updateCanSwitchWallet() - reviewFundingSourceChange(current, canReviewInPlace) + reviewFundingSourceChange(current, canReviewInPlace, wasReviewed) } - private suspend fun reviewFundingSourceChange(previous: SendUiState, rebindBitcoinTerms: Boolean) { + private suspend fun reviewFundingSourceChange( + previous: SendUiState, + rebindBitcoinTerms: Boolean, + wasReviewed: Boolean, + ) { + if (switchPaykitRequestAsset(previous, wasReviewed)) return if (rebindBitcoinTerms && previous.paykitMethod() != _sendUiState.value.paykitMethod()) { paykitPaymentTerms = null paykitReviewedAmount = null @@ -3813,7 +3865,8 @@ class AppViewModel @Inject constructor( (source == SendFundingSource.Savings || source == SendFundingSource.Spending) private fun nextFundingSource(current: SendUiState, requested: SendFundingSource?): SendFundingSource? { - val sources = availableFundingSources(current).filter { requested != null || it != SendFundingSource.Usdt } + val includesUsdt = requested != null || activeIncomingPaymentRequest() != null + val sources = availableFundingSources(current).filter { includesUsdt || it != SendFundingSource.Usdt } if (sources.isEmpty() || requested != null && requested !in sources) return null val selected = current.selectedFundingSource() return (requested ?: sources.nextAfter(selected)).takeUnless { it == selected } @@ -4005,7 +4058,7 @@ class AppViewModel @Inject constructor( setSendEffect(SendEffect.NavigateToConfirm) } - private suspend fun reviewPaykitAmount(): Boolean { + private suspend fun reviewPaykitAmount(navigateToUsdtReview: Boolean = true): Boolean { val paykit = synchronized(contactPaymentContextLock) { activeContactPaymentContext } if (paykit == null || paykit.endpoints.isEmpty()) return true val current = _sendUiState.value @@ -4047,7 +4100,7 @@ class AppViewModel @Inject constructor( } paykitReviewedAmount = payment paykitPaymentTerms = terms - if (current.paykitUsesUsdt) setSendEffect(SendEffect.NavigateToUsdtReview) + if (current.paykitUsesUsdt && navigateToUsdtReview) setSendEffect(SendEffect.NavigateToUsdtReview) return !current.paykitUsesUsdt } @@ -8107,6 +8160,14 @@ data class SendUiState( val canSwitchBitcoinFundingSource: Boolean get() = fundingOptions.count { it.enabled && it.source != SendFundingSource.Usdt } > 1 + /** A payment request's confirm step also switches between bitcoin and USDT. */ + val canSwitchConfirmFundingSource: Boolean + get() = if (incomingPaymentRequestId != null && usdtRecipient != null) { + fundingOptions.count { it.enabled } > 1 + } else { + canSwitchBitcoinFundingSource + } + val fixedLightningAmount: ULong? get() = decodedInvoice?.amountSatoshis?.takeIf { it > 0u && !paykitUsesUsdt && payMethod == SendMethod.LIGHTNING && lnurl == null @@ -8191,6 +8252,7 @@ sealed class SendEffect { data object NavigateToScan : SendEffect() data object NavigateToConfirm : SendEffect() data object NavigateToUsdtReview : SendEffect() + data class ShowPaykitConfirmation(val route: SendRoute) : SendEffect() data object NavigateToHardwareSign : SendEffect() data object NavigateToWithdrawConfirm : SendEffect() data object NavigateToWithdrawError : SendEffect() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d940bcf647..78682ad8f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1222,6 +1222,7 @@ USDT is currently unavailable. Please try again later. This address uses another wallet’s smart account. Disable its smart account mode there before sending with Bitkit. The fee quote expired. Review the payment again. + The fee quote expired and was updated. Check the fee and swipe again. Unable to pay the transaction fee in USDT. Try a smaller amount. Could not update USDT. Check your connection and try again. A payment is still pending. Wait for confirmation before sending again. diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt index 466c703683..6fd99c3aa2 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt @@ -366,6 +366,41 @@ class UsdtViewModelTest : BaseUnitTest() { verify(repo, times(1)).send(eq(quote), anyOrNull(), any()) } + @Test + fun `expired quote is replaced on review with a notice to swipe again`() = test { + val viewModel = createViewModel() + settings.value = settings.value.copy(isPinForPaymentsEnabled = false) + whenever(repo.send(eq(quote), anyOrNull(), any())) + .thenReturn(Result.failure(UsdtException.QuoteExpired())) + viewModel.quote(quote.recipient, "1", quote.destination).join() + viewModel.confirm().join() + assertNull(viewModel.state.value.quote) + assertEquals(R.string.usdt__error_expired, viewModel.state.value.error) + + viewModel.requote(quote.recipient, "1", quote.destination).join() + + assertEquals(quote, viewModel.state.value.quote) + assertEquals(R.string.usdt__error_quote_refreshed, viewModel.state.value.error) + verify(repo, times(2)).quote(any(), any(), any(), any()) + verify(repo, times(1)).send(eq(quote), anyOrNull(), any()) + } + + @Test + fun `requote leaves other errors for the user to review`() = test { + val viewModel = createViewModel() + settings.value = settings.value.copy(isPinForPaymentsEnabled = false) + whenever(repo.send(eq(quote), anyOrNull(), any())) + .thenReturn(Result.failure(UsdtException.SubmissionDeadlineExpired())) + viewModel.quote(quote.recipient, "1", quote.destination).join() + viewModel.confirm().join() + + viewModel.requote(quote.recipient, "1", quote.destination).join() + + assertNull(viewModel.state.value.quote) + assertEquals(R.string.wallet__payment_request_expired, viewModel.state.value.error) + verify(repo, times(1)).quote(any(), any(), any(), any()) + } + @Test fun `durably recorded send failure retains the payment and prevents resubmission`() = test { val retained: UsdtTransfer = mock() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 94257e20d6..eb4596afc0 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -14096,7 +14096,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `fixed dollar request keeps asset selection only when USDT is enabled`() = test { + fun `fixed dollar request opens confirmation whether or not USDT is enabled`() = test { val bitcoin = Endpoint(MethodId.P2wpkh, REGTEST_ADDRESS, rawPayload = REGTEST_ADDRESS) val usdt = Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", rawPayload = "") val endpoints = listOf(bitcoin, usdt) @@ -14122,7 +14122,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { endpoints = endpoints, ) advanceUntilIdle() - assertEquals(Sheet.Send(if (enabled) SendRoute.Amount else SendRoute.Confirm), sut.currentSheet.value) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) assertEquals(2_500uL, sut.sendUiState.value.amount) assertFalse(sut.sendUiState.value.isAmountInputValid) assertFalse(sut.sendUiState.value.shouldAutomaticallyPay) @@ -14223,7 +14223,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `automatic USDT request finishes preparation and admits amount continue`() = test { + fun `automatic USDT request finishes preparation on its review`() = test { enablePaykitUi() pubkyPublicKey.value = testPublicKey sut.setIsAuthenticated(true) @@ -14247,16 +14247,85 @@ class AppViewModelSendFlowTest : BaseUnitTest() { Result.success(PublicPaykitPaymentResult.Opened(endpoint.value, endpoints = listOf(endpoint))) ) runCurrent() - assertEquals(Sheet.Send(SendRoute.Amount), awaitItem()) + assertEquals(Sheet.Send(SendRoute.UsdtReview), awaitItem()) expectNoEvents() } expectNoEvents() assertTrue(sut.sendUiState.value.paykitUsesUsdt) - sut.setSendEvent(SendEvent.AmountContinue) - assertEquals(SendEffect.NavigateToUsdtReview, awaitItem()) + assertEquals("0.05", sut.sendUiState.value.paykitAmount) } } + @Test + fun `fixed request switches asset on its confirmation instead of returning to amount`() = test { + val bitcoin = Endpoint(MethodId.P2wpkh, REGTEST_ADDRESS, rawPayload = REGTEST_ADDRESS) + val usdt = Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", rawPayload = "") + val endpoints = listOf(bitcoin, usdt) + val request = paymentRequest().copy( + amount = PaykitAmount(PaykitAsset.USD, 1_000u), + pricing = PaykitRequestPricing( + PaymentConversion.Fixed( + listOf(ConversionRate("btc", "0.00001"), ConversionRate("usdt", "1")), + ) + ), + acceptedPaymentEndpointIdentifiers = endpoints.map { it.methodId.rawValue }, + ) + whenever(coreService.decode(REGTEST_ADDRESS)).thenReturn( + Scanner.OnChain(OnChainInvoice(REGTEST_ADDRESS, 0u, null, null, null)), + ) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + usdtState.value = UsdtWalletState(balance = 50_000_000u) + usdtEnabled.value = true + runCurrent() + + sut.openContactPayment( + REGTEST_ADDRESS, + testPublicKey, + incomingPaymentRequest = request, + endpoints = endpoints, + ) + advanceUntilIdle() + assertEquals(Sheet.Send(SendRoute.UsdtReview), sut.currentSheet.value) + assertTrue(sut.sendUiState.value.paykitUsesUsdt) + assertTrue(sut.sendUiState.value.canSwitchConfirmFundingSource) + + sut.sendEffect.test { + sut.setSendEvent(SendEvent.PaymentMethodSwitch) + assertEquals(SendEffect.ShowPaykitConfirmation(SendRoute.Confirm), awaitItem()) + advanceUntilIdle() + assertFalse(sut.sendUiState.value.paykitUsesUsdt) + assertEquals(10_000uL, sut.sendUiState.value.amount) + + sut.setSendEvent(SendEvent.PaymentMethodSwitch) + assertEquals(SendEffect.ShowPaykitConfirmation(SendRoute.UsdtReview), awaitItem()) + advanceUntilIdle() + assertTrue(sut.sendUiState.value.paykitUsesUsdt) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `fixed USDT request without enough USDT opens amount`() = test { + val endpoint = Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", rawPayload = "") + val request = paymentRequest().copy( + amount = PaykitAmount(PaykitAsset.USDT, 5_000_000u), + acceptedPaymentEndpointIdentifiers = listOf(endpoint.methodId.rawValue), + ) + usdtEnabled.value = true + runCurrent() + + sut.openContactPayment( + endpoint.value, + testPublicKey, + incomingPaymentRequest = request, + endpoints = listOf(endpoint), + ) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Amount), sut.currentSheet.value) + assertFalse(sut.sendUiState.value.isAmountInputValid) + } + @Test fun `converted request cannot enter review without a current exchange rate`() = test { val endpoint = Endpoint( diff --git a/changelog.d/next/1623.fixed.md b/changelog.d/next/1623.fixed.md new file mode 100644 index 0000000000..dbd7058e59 --- /dev/null +++ b/changelog.d/next/1623.fixed.md @@ -0,0 +1 @@ +Fixed payment requests with a set amount moving to the Amount step on their own; they now open on confirmation, where Pay From switches between bitcoin and USDT and an expired USDT fee quote refreshes in place. From a89d7129b7194175f46502011b0d079a259be30e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 01:35:40 +0200 Subject: [PATCH 2/4] fix: requote an expired usdt fee only once per swipe --- .../ui/screens/wallets/usdt/UsdtViewModel.kt | 7 ++++++- .../screens/wallets/usdt/UsdtViewModelTest.kt | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt index c0f24028c3..3e45ea4846 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt @@ -246,9 +246,13 @@ class UsdtViewModel @Inject constructor( fun cancelAuthentication() = _state.update { it.copy(authenticationRequired = false) } fun edit() = _state.update { UsdtSendState(refreshError = it.refreshError) } + // One automatic requote per expired submission; a later expiry waits for the user's own retry. + private var requotedExpiredSubmission = false + /** Replaces an expired quote and keeps the review, telling the user to check the new fee and swipe again. */ fun requote(recipient: String, amount: String, destination: UsdtDestination) = viewModelScope.launch { - if (_state.value.error != R.string.usdt__error_expired) return@launch + if (_state.value.error != R.string.usdt__error_expired || requotedExpiredSubmission) return@launch + requotedExpiredSubmission = true _state.update { it.copy(error = null) } quote(recipient, amount, destination).join() _state.update { if (it.quote != null) it.copy(error = R.string.usdt__error_quote_refreshed) else it } @@ -261,6 +265,7 @@ class UsdtViewModel @Inject constructor( if (_state.value.busy) return@launch maximumQuote = null preparedQuote = null + requotedExpiredSubmission = false _state.update { it.copy(busy = true, error = null) } runSuspendCatching { val payment = sendPayment diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt index 6fd99c3aa2..e85b5d4fc9 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt @@ -385,6 +385,24 @@ class UsdtViewModelTest : BaseUnitTest() { verify(repo, times(1)).send(eq(quote), anyOrNull(), any()) } + @Test + fun `expired replacement quote is not requoted again until the next swipe`() = test { + val viewModel = createViewModel() + settings.value = settings.value.copy(isPinForPaymentsEnabled = false) + whenever(repo.send(eq(quote), anyOrNull(), any())) + .thenReturn(Result.failure(UsdtException.QuoteExpired())) + viewModel.quote(quote.recipient, "1", quote.destination).join() + viewModel.confirm().join() + whenever(repo.quote(any(), any(), any(), any())).thenReturn(Result.failure(UsdtException.QuoteExpired())) + + viewModel.requote(quote.recipient, "1", quote.destination).join() + viewModel.requote(quote.recipient, "1", quote.destination).join() + + assertNull(viewModel.state.value.quote) + assertEquals(R.string.usdt__error_expired, viewModel.state.value.error) + verify(repo, times(2)).quote(any(), any(), any(), any()) + } + @Test fun `requote leaves other errors for the user to review`() = test { val viewModel = createViewModel() From 14740cab1f3ef6d0f3d55e9f97b0c3ff58d91f77 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 01:38:31 +0200 Subject: [PATCH 3/4] fix: replace the paykit confirmation on asset switch and keep the quote on cancel --- .../screens/wallets/usdt/PaykitUsdtReview.kt | 2 +- .../ui/screens/wallets/usdt/UsdtViewModel.kt | 3 +++ .../java/to/bitkit/ui/sheets/SendSheet.kt | 7 +++++-- .../screens/wallets/usdt/UsdtViewModelTest.kt | 19 +++++++++++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt index f65356de53..88e9015527 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/PaykitUsdtReview.kt @@ -88,7 +88,7 @@ fun PaykitUsdtReview( confirmText = stringResource(R.string.wallet__send_yes), dismissText = stringResource(R.string.common__cancel), onConfirm = viewModel::acceptWarning, - onDismiss = viewModel::edit + onDismiss = viewModel::dismissWarning ) } val transfer = wallet.transfers.firstOrNull { it.id == state.quote?.id } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt index 3e45ea4846..2a99f07197 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModel.kt @@ -246,6 +246,9 @@ class UsdtViewModel @Inject constructor( fun cancelAuthentication() = _state.update { it.copy(authenticationRequired = false) } fun edit() = _state.update { UsdtSendState(refreshError = it.refreshError) } + /** Keeps the reviewed quote when a warning is declined; the warning shows again on the next swipe. */ + fun dismissWarning() = _state.update { it.copy(warning = null) } + // One automatic requote per expired submission; a later expiry waits for the user's own retry. private var requotedExpiredSubmission = false 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 17edaf64e6..1d9f38760b 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -270,9 +270,12 @@ fun SendSheet( is SendEffect.NavigateToConfirm -> navController.navigateTo(SendRoute.Confirm) is SendEffect.NavigateToUsdtReview -> navController.navigateTo(SendRoute.UsdtReview) is SendEffect.ShowPaykitConfirmation -> { - if (!navController.popBackStack(it.route, inclusive = false)) { - navController.navigateTo(it.route) + // Replace the other asset's confirmation, so Back never returns to its stale terms. + val replaced = when (it.route) { + SendRoute.Confirm -> SendRoute.UsdtReview + else -> SendRoute.Confirm } + navController.navigateTo(it.route) { popUpTo(replaced) { inclusive = true } } } is SendEffect.NavigateToHardwareSign -> navController.navigateTo(SendRoute.HardwareSign) is SendEffect.PopBack -> navController.popBackStack(it.route, inclusive = false) diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt index e85b5d4fc9..be8bd98286 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtViewModelTest.kt @@ -37,6 +37,7 @@ import java.io.IOException import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -403,6 +404,24 @@ class UsdtViewModelTest : BaseUnitTest() { verify(repo, times(2)).quote(any(), any(), any(), any()) } + @Test + fun `declining a warning keeps the reviewed quote and warns again on the next swipe`() = test { + val viewModel = createViewModel(walletState = UsdtWalletState(balance = 1u)) + settings.value = settings.value.copy(isPinForPaymentsEnabled = false) + viewModel.quote(quote.recipient, "1", quote.destination).join() + viewModel.confirm().join() + val warning = assertNotNull(viewModel.state.value.warning) + + viewModel.dismissWarning() + + assertEquals(quote, viewModel.state.value.quote) + assertNull(viewModel.state.value.warning) + viewModel.confirm().join() + assertEquals(warning, viewModel.state.value.warning) + verify(repo, times(1)).quote(any(), any(), any(), any()) + verify(repo, never()).send(any(), anyOrNull(), any()) + } + @Test fun `requote leaves other errors for the user to review`() = test { val viewModel = createViewModel() From 17e1b0cb21dbd42a57d98cba3d834104aa9ae756 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 14:54:41 +0200 Subject: [PATCH 4/4] fix: disable pay from on a usdt review without a payment request --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 8 ++--- .../viewmodels/AppViewModelSendFlowTest.kt | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 916db9221a..f116464611 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -8162,10 +8162,10 @@ data class SendUiState( /** A payment request's confirm step also switches between bitcoin and USDT. */ val canSwitchConfirmFundingSource: Boolean - get() = if (incomingPaymentRequestId != null && usdtRecipient != null) { - fundingOptions.count { it.enabled } > 1 - } else { - canSwitchBitcoinFundingSource + get() = when { + incomingPaymentRequestId != null && usdtRecipient != null -> fundingOptions.count { it.enabled } > 1 + paykitUsesUsdt -> false + else -> canSwitchBitcoinFundingSource } val fixedLightningAmount: ULong? diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index eb4596afc0..f138260b0b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -14304,6 +14304,40 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `contact USDT review without a request keeps the pay from chip disabled`() = test { + val bolt11 = "lnbcrt1contactamountless" + stubLightningScan(bolt11, amountSats = 0u) + whenever(coreService.decode(REGTEST_ADDRESS)).thenReturn( + Scanner.OnChain(OnChainInvoice(REGTEST_ADDRESS, 0u, null, null, null)), + ) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u) + usdtState.value = UsdtWalletState(balance = 50_000_000u) + usdtEnabled.value = true + runCurrent() + sut.openContactPayment( + paymentRequest = bolt11, + publicKey = testPublicKey, + endpoints = listOf( + Endpoint(MethodId.Bolt11, bolt11, rawPayload = ""), + Endpoint(MethodId.P2wpkh, REGTEST_ADDRESS, rawPayload = ""), + Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", rawPayload = ""), + ), + ) + advanceUntilIdle() + sut.setSendEvent(SendEvent.PaymentMethodSelect(SendFundingSource.Usdt)) + advanceUntilIdle() + sut.onUsdtAmountChange("5") + sut.sendEffect.test { + sut.setSendEvent(SendEvent.AmountContinue) + assertEquals(SendEffect.NavigateToUsdtReview, awaitItem()) + } + assertTrue(sut.sendUiState.value.paykitUsesUsdt) + assertNull(sut.sendUiState.value.incomingPaymentRequestId) + + assertFalse(sut.sendUiState.value.canSwitchConfirmFundingSource) + } + @Test fun `fixed USDT request without enough USDT opens amount`() = test { val endpoint = Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", rawPayload = "")