From 1568abb7416a8c0d80954e661d9723ceea2d7509 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 21 Oct 2026 01:23:05 +0200 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 = "") From c66819be00548a10e7edb91418f149cc7aa8af9b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 26 Oct 2026 06:52:03 +0100 Subject: [PATCH 05/10] fix: translate the refreshed usdt fee quote notice --- app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-b+es+419/strings.xml | 1 + app/src/main/res/values-ca/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-el/strings.xml | 1 + app/src/main/res/values-es-rES/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-pl/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-pt/strings.xml | 1 + app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 2 +- 16 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 7538ff848a..41e7a65a18 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -805,6 +805,7 @@ تعذر دفع رسوم المعاملة بـ USDT. جرّب مبلغًا أصغر. تعذر تحديث USDT. تحقق من اتصالك وحاول مرة أخرى. لا يزال هناك دفع معلق. انتظر التأكيد قبل الإرسال مرة أخرى. + انتهت صلاحية عرض الرسوم وتم تحديثه. تحقق من الرسوم واسحب مرة أخرى. طلبات كثيرة جدًا. يرجى المحاولة مرة أخرى بعد قليل. رفضت شبكة USDT الطلب. يرجى المحاولة مرة أخرى. أعادت خدمة USDT استجابة غير صالحة. يرجى المحاولة مرة أخرى لاحقًا. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index c3c45f5a99..f7030b4b0d 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -822,6 +822,7 @@ No se pudo pagar la comisión de la transacción en USDT. Pruebe con un monto menor. No se pudo actualizar USDT. Revise su conexión y vuelva a intentarlo. Aún hay un pago pendiente. Espere a que se confirme antes de volver a enviar. + La cotización de la comisión venció y se actualizó. Revise la comisión y deslice de nuevo. Demasiadas solicitudes. Vuelva a intentarlo en breve. La red USDT rechazó la solicitud. Vuelva a intentarlo. El servicio USDT devolvió una respuesta no válida. Vuelva a intentarlo más tarde. diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 15471de193..b5cd0150fc 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -815,6 +815,7 @@ No es pot pagar la tarifa de la transacció en USDT. Prova amb un import més petit. No s\'ha pogut actualitzar USDT. Comprova la connexió i torna-ho a provar. Encara hi ha un pagament pendent. Espera la confirmació abans de tornar a enviar. + La cotització de la tarifa ha caducat i s’ha actualitzat. Revisa la tarifa i torna a lliscar. Massa sol·licituds. Torna-ho a provar d\'aquí a una estona. La xarxa USDT ha rebutjat la sol·licitud. Torna-ho a provar. El servei USDT ha retornat una resposta no vàlida. Torna-ho a provar més tard. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index e96abf36d5..1c6084f0ef 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -819,6 +819,7 @@ Transakční poplatek nelze zaplatit v USDT. Zkuste menší částku. USDT se nepodařilo aktualizovat. Zkontrolujte připojení a zkuste to znovu. Platba stále čeká na vyřízení. Před dalším odesláním počkejte na potvrzení. + Nabídka poplatku vypršela a byla aktualizována. Zkontrolujte poplatek a přejeďte znovu. Příliš mnoho požadavků. Zkuste to prosím za chvíli. Síť USDT požadavek odmítla. Zkuste to prosím znovu. Služba USDT vrátila neplatnou odpověď. Zkuste to prosím později. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 7082a0bad2..55be65adb8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -817,6 +817,7 @@ Die Transaktionsgebühr kann nicht in USDT bezahlt werden. Versuche einen kleineren Betrag. USDT konnte nicht aktualisiert werden. Prüfe deine Verbindung und versuche es erneut. Eine Zahlung ist noch ausstehend. Warte auf die Bestätigung, bevor du erneut sendest. + Das Gebührenangebot ist abgelaufen und wurde aktualisiert. Prüfe die Gebühr und wische erneut. Zu viele Anfragen. Bitte versuche es gleich erneut. Das USDT-Netzwerk hat die Anfrage abgelehnt. Bitte versuche es erneut. Der USDT-Dienst hat eine ungültige Antwort zurückgegeben. Bitte versuche es später erneut. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index faa0810cef..8d1eed7b61 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -807,6 +807,7 @@ Δεν είναι δυνατή η πληρωμή του τέλους συναλλαγής σε USDT. Δοκίμασε μικρότερο ποσό. Δεν ήταν δυνατή η ενημέρωση του USDT. Έλεγξε τη σύνδεσή σου και δοκίμασε ξανά. Μια πληρωμή εκκρεμεί ακόμα. Περίμενε την επιβεβαίωση πριν στείλεις ξανά. + Η προσφορά τέλους έληξε και ενημερώθηκε. Έλεγξε το τέλος και σύρε ξανά. Πάρα πολλά αιτήματα. Δοκίμασε ξανά σε λίγο. Το δίκτυο USDT απέρριψε το αίτημα. Δοκίμασε ξανά. Η υπηρεσία USDT επέστρεψε μη έγκυρη απάντηση. Δοκίμασε ξανά αργότερα. diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index c0bbaa01f6..ab68e1bc7f 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -818,6 +818,7 @@ No se pudo pagar la comisión de la transacción en USDT. Prueba con una cantidad menor. No se pudo actualizar USDT. Comprueba tu conexión e inténtalo de nuevo. Aún hay un pago pendiente. Espera a que se confirme antes de volver a enviar. + La cotización de la comisión ha caducado y se ha actualizado. Revisa la comisión y desliza de nuevo. Demasiadas solicitudes. Inténtalo de nuevo en breve. La red USDT rechazó la solicitud. Inténtalo de nuevo. El servicio USDT devolvió una respuesta no válida. Inténtalo de nuevo más tarde. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7335ef012c..a138264108 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -815,6 +815,7 @@ No se pudo pagar la comisión de la transacción en USDT. Pruebe con una cantidad menor. No se pudo actualizar USDT. Compruebe su conexión e inténtelo de nuevo. Aún hay un pago pendiente. Espere a que se confirme antes de volver a enviar. + La cotización de la comisión ha caducado y se ha actualizado. Revise la comisión y deslice de nuevo. Demasiadas solicitudes. Inténtelo de nuevo en breve. La red USDT rechazó la solicitud. Inténtelo de nuevo. El servicio USDT devolvió una respuesta no válida. Inténtelo de nuevo más tarde. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 331bf6b5b7..944894e227 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -819,6 +819,7 @@ Impossible de payer les frais de transaction en USDT. Essayez un montant plus faible. Impossible de mettre à jour USDT. Vérifiez votre connexion et réessayez. Un paiement est toujours en attente. Attendez sa confirmation avant d\'envoyer à nouveau. + Le devis des frais a expiré et a été mis à jour. Vérifiez les frais et faites glisser à nouveau. Trop de requêtes. Veuillez réessayer dans un instant. Le réseau USDT a rejeté la demande. Veuillez réessayer. Le service USDT a renvoyé une réponse non valide. Veuillez réessayer plus tard. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 052b904132..ac67d1e634 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -818,6 +818,7 @@ Impossibile pagare la commissione di transazione in USDT. Prova con un importo minore. Impossibile aggiornare USDT. Controlla la connessione e riprova. Un pagamento è ancora in sospeso. Attendi la conferma prima di inviare di nuovo. + Il preventivo della commissione è scaduto ed è stato aggiornato. Controlla la commissione e scorri di nuovo. Troppe richieste. Riprova tra poco. La rete USDT ha rifiutato la richiesta. Riprova. Il servizio USDT ha restituito una risposta non valida. Riprova più tardi. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 7bf53e4b5c..d045b4f074 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -814,6 +814,7 @@ Kan de transactievergoeding niet in USDT betalen. Probeer een kleiner bedrag. Kon USDT niet bijwerken. Controleer uw verbinding en probeer het opnieuw. Er is nog een betaling in behandeling. Wacht op de bevestiging voordat u opnieuw verzendt. + De vergoedingsofferte is verlopen en bijgewerkt. Controleer de vergoeding en veeg opnieuw. Te veel verzoeken. Probeer het zo meteen opnieuw. Het USDT-netwerk heeft het verzoek geweigerd. Probeer het opnieuw. De USDT-dienst gaf een ongeldig antwoord. Probeer het later opnieuw. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 8a8b224573..1cedf905d3 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -821,6 +821,7 @@ Nie można zapłacić opłaty transakcyjnej w USDT. Spróbuj mniejszej kwoty. Nie udało się zaktualizować USDT. Sprawdź połączenie i spróbuj ponownie. Płatność wciąż oczekuje. Poczekaj na potwierdzenie, zanim wyślesz ponownie. + Wycena opłaty wygasła i została zaktualizowana. Sprawdź opłatę i przesuń ponownie. Zbyt wiele żądań. Spróbuj ponownie za chwilę. Sieć USDT odrzuciła żądanie. Spróbuj ponownie. Usługa USDT zwróciła nieprawidłową odpowiedź. Spróbuj ponownie później. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 0ba777aeb7..f6ea06c6f2 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -822,6 +822,7 @@ Não foi possível pagar a taxa da transação em USDT. Tente um valor menor. Não foi possível atualizar o USDT. Verifique sua conexão e tente novamente. Ainda há um pagamento pendente. Aguarde a confirmação antes de enviar novamente. + A cotação da taxa expirou e foi atualizada. Confira a taxa e deslize novamente. Muitas solicitações. Tente novamente em instantes. A rede USDT rejeitou a solicitação. Tente novamente. O serviço USDT retornou uma resposta inválida. Tente novamente mais tarde. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index f2d6301a59..01ca6580cc 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1029,6 +1029,7 @@ Não foi possível pagar a taxa da transação em USDT. Tente um montante menor. Não foi possível atualizar o USDT. Verifique a ligação e tente novamente. Ainda há um pagamento pendente. Aguarde a confirmação antes de enviar novamente. + A cotação da taxa expirou e foi atualizada. Verifique a taxa e deslize novamente. Demasiados pedidos. Tente novamente daqui a pouco. A rede USDT rejeitou o pedido. Tente novamente. O serviço USDT devolveu uma resposta inválida. Tente novamente mais tarde. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 511e367cf2..798869c687 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -840,6 +840,7 @@ Не удалось оплатить комиссию за транзакцию в USDT. Попробуйте меньшую сумму. Не удалось обновить USDT. Проверьте подключение и попробуйте ещё раз. Платёж всё ещё в ожидании. Дождитесь подтверждения, прежде чем отправлять снова. + Расчёт комиссии устарел и был обновлён. Проверьте комиссию и проведите ещё раз. Слишком много запросов. Попробуйте ещё раз чуть позже. Сеть USDT отклонила запрос. Попробуйте ещё раз. Сервис USDT вернул некорректный ответ. Попробуйте позже. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0b7d875776..143ea963a6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1271,10 +1271,10 @@ 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. + The fee quote expired and was updated. Check the fee and swipe again. Too many requests. Please try again shortly. The USDT network rejected the request. Please try again. The USDT service returned an invalid response. Please try again later. From 56b680c8a440389d66900f0f5d95e7e82ddadd5a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 28 Oct 2026 08:21:25 +0100 Subject: [PATCH 06/10] fix: amount fallback replaces a switched confirmation --- .../java/to/bitkit/ui/sheets/SendSheet.kt | 3 +- .../java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../ui/sheets/SendSheetNavigationTest.kt | 20 +++++++++++ .../viewmodels/AppViewModelSendFlowTest.kt | 35 +++++++++++++++++++ 4 files changed, 58 insertions(+), 2 deletions(-) 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 07be2b493f..6bb5641536 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -121,7 +121,8 @@ internal fun NavController.navigateToSendAmount(startDestination: SendRoute) { if (popBackStack(SendRoute.Amount, inclusive = false)) return navigateTo(SendRoute.Amount) { if (startDestination == SendRoute.Confirm || startDestination == SendRoute.UsdtReview) { - popUpTo(startDestination) { inclusive = true } + // A Pay From switch may have replaced the starting confirmation, so clear whichever one is there. + popUpTo(graph.id) } } } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index e8f29cee4b..184292fa41 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4009,7 +4009,7 @@ class AppViewModel @Inject constructor( paymentAmountForSource(current, nextSource) } catch (_: PaykitAmountError.RateUnavailable) { _sendUiState.update { it.copy(paykitRateUnavailable = true) } - if (canReviewInPlace) { + if (wasReviewed) { paykitPaymentTerms = null paykitReviewedAmount = null setSendEffect(SendEffect.NavigateToAmount) diff --git a/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt b/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt index df2c6367ba..eb65547d98 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt @@ -99,4 +99,24 @@ class SendSheetNavigationTest { } } } + + @Test + fun `amount fallback after a pay from switch clears the replacing confirmation`() { + val switches = listOf(SendRoute.UsdtReview to SendRoute.Confirm, SendRoute.Confirm to SendRoute.UsdtReview) + for ((start, switched) in switches) { + val navController = NavHostController(ApplicationProvider.getApplicationContext()).apply { + navigatorProvider.addNavigator(ComposeNavigator()) + graph = createGraph(startDestination = start) { + composable {} + composable {} + composable {} + } + } + navController.navigateTo(switched) { popUpTo(start) { inclusive = true } } + navController.navigateToSendAmount(start) + assertTrue(navController.currentDestination?.hasRoute() == true) + navController.popBackStack() + assertNull(navController.currentDestination) + } + } } diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index f92c784b5e..8360f0e072 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -14983,6 +14983,41 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } } + @Test + fun `pay from toward USDT returns to amount when the request has no USDT rate`() = 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")))), + 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.Confirm), sut.currentSheet.value) + + sut.sendEffect.test { + sut.setSendEvent(SendEvent.PaymentMethodSelect(SendFundingSource.Usdt)) + advanceUntilIdle() + assertEquals(SendEffect.NavigateToAmount, awaitItem()) + assertTrue(sut.sendUiState.value.paykitRateUnavailable) + expectNoEvents() + } + } + @Test fun `contact USDT review without a request keeps the pay from chip disabled`() = test { val bolt11 = "lnbcrt1contactamountless" From ee6edc3d89cd10402ecdb3586b19d9f439412963 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 28 Oct 2026 10:36:42 +0100 Subject: [PATCH 07/10] fix: keep the confirmation when the other asset has no rate --- .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 11 ++++++++++- .../bitkit/viewmodels/AppViewModelSendFlowTest.kt | 14 +++++++++++--- 2 files changed, 21 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 184292fa41..b21166dca7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4008,8 +4008,17 @@ class AppViewModel @Inject constructor( val converted = try { paymentAmountForSource(current, nextSource) } catch (_: PaykitAmountError.RateUnavailable) { + if (wasReviewed && !canReviewInPlace) { + // The reviewed quote is still payable, so keep its confirmation and only report the missing rate. + toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.wallet__payment_request_rate_unavailable), + testTag = "PaykitRateUnavailableToast", + ) + return + } _sendUiState.update { it.copy(paykitRateUnavailable = true) } - if (wasReviewed) { + if (canReviewInPlace) { paykitPaymentTerms = null paykitReviewedAmount = null setSendEffect(SendEffect.NavigateToAmount) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 8360f0e072..c833bcaad0 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -14984,7 +14984,8 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `pay from toward USDT returns to amount when the request has no USDT rate`() = test { + fun `pay from toward USDT keeps the confirmation when the request has no USDT rate`() = test { + whenever(context.getString(R.string.wallet__payment_request_rate_unavailable)).thenReturn("Rates unavailable") val bitcoin = Endpoint(MethodId.P2wpkh, REGTEST_ADDRESS, rawPayload = REGTEST_ADDRESS) val usdt = Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", rawPayload = "") val endpoints = listOf(bitcoin, usdt) @@ -15009,13 +15010,20 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + clearInvocations(toastManager) sut.sendEffect.test { sut.setSendEvent(SendEvent.PaymentMethodSelect(SendFundingSource.Usdt)) advanceUntilIdle() - assertEquals(SendEffect.NavigateToAmount, awaitItem()) - assertTrue(sut.sendUiState.value.paykitRateUnavailable) expectNoEvents() } + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertFalse(sut.sendUiState.value.paykitUsesUsdt) + verify(toastManager).enqueue( + check { + assertEquals(Toast.ToastType.WARNING, it.type) + assertEquals("Rates unavailable", it.title) + } + ) } @Test From 17b60ca7d354576f306e1601c5515ec2072753b8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 28 Oct 2026 11:17:02 +0100 Subject: [PATCH 08/10] fix: skip unpriced sources in the pay from cycle --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 6 ++- .../viewmodels/AppViewModelSendFlowTest.kt | 50 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b21166dca7..e863d5ff75 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4082,9 +4082,13 @@ class AppViewModel @Inject constructor( 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 } + val priced = sources.filter { it == selected || hasPaymentRate(current, it) }.takeIf { it.size > 1 } ?: sources + return (requested ?: priced.nextAfter(selected)).takeUnless { it == selected } } + private fun hasPaymentRate(current: SendUiState, source: SendFundingSource): Boolean = + runCatching { paymentAmountForSource(current, source) }.exceptionOrNull() !is PaykitAmountError.RateUnavailable + private fun reviewPaykitMethodChange(previous: SendUiState): Boolean { if (previous.paykitMethod() == _sendUiState.value.paykitMethod() || activeIncomingPaymentRequest()?.pricing?.conversion == null diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index c833bcaad0..87b8c54f93 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -14773,6 +14773,56 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull(), anyOrNull()) } + @Test + fun `pay from cycle skips USDT when the request has no USDT rate`() = test { + val lnurl = LnurlPayData( + uri = "lnurl1request", + callback = "https://example.com/pay", + minSendable = 1_000_000u, + maxSendable = 100_000_000u, + metadataStr = "[]", + commentAllowed = null, + allowsNostr = false, + nostrPubkey = null, + ) + val endpoints = listOf( + Endpoint(MethodId.P2wpkh, REGTEST_ADDRESS, rawPayload = REGTEST_ADDRESS), + Endpoint(MethodId.Lnurl, lnurl.uri, rawPayload = ""), + Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", rawPayload = ""), + ) + val request = paymentRequest().copy( + amount = PaykitAmount(PaykitAsset.USD, 250u), + pricing = PaykitRequestPricing(PaymentConversion.Fixed(listOf(ConversionRate("btc", "0.00001")))), + acceptedPaymentEndpointIdentifiers = endpoints.map { it.methodId.rawValue }, + ) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u) + usdtState.value = UsdtWalletState(balance = 50_000_000u) + usdtEnabled.value = true + whenever(lightningRepo.canSend(any())).thenReturn(true) + setActiveContactPaymentContext(testPublicKey, incomingPaymentRequest = request, endpoints = endpoints) + setUnifiedState(amount = 2_500u, payMethod = SendMethod.ONCHAIN) + setSendState( + sut.sendUiState.value.copy( + lnurl = LnurlParams.LnurlPay(lnurl), + usdtRecipient = "0x1111111111111111111111111111111111111111", + ) + ) + sut.sendEffect.test { + sut.setSendEvent(SendEvent.AmountContinue) + advanceUntilIdle() + assertEquals(SendEffect.NavigateToConfirm, awaitItem()) + clearInvocations(toastManager) + for (expected in listOf(SendMethod.LIGHTNING, SendMethod.ONCHAIN, SendMethod.LIGHTNING)) { + sut.setSendEvent(SendEvent.PaymentMethodSwitch) + advanceUntilIdle() + assertFalse(sut.sendUiState.value.paykitUsesUsdt) + assertEquals(expected, sut.sendUiState.value.payMethod) + } + cancelAndIgnoreRemainingEvents() + } + verify(toastManager, never()).enqueue(any()) + } + @Test fun `fixed dollar request opens confirmation whether or not USDT is enabled`() = test { val bitcoin = Endpoint(MethodId.P2wpkh, REGTEST_ADDRESS, rawPayload = REGTEST_ADDRESS) From 5e988c30d118ccb51fae6feb8cfaadfa3e53223a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 28 Oct 2026 11:46:50 +0100 Subject: [PATCH 09/10] fix: probe only request pricing in the pay from cycle --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 7 +++- .../viewmodels/AppViewModelSendFlowTest.kt | 40 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index e863d5ff75..6ac033e4a3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4086,8 +4086,11 @@ class AppViewModel @Inject constructor( return (requested ?: priced.nextAfter(selected)).takeUnless { it == selected } } - private fun hasPaymentRate(current: SendUiState, source: SendFundingSource): Boolean = - runCatching { paymentAmountForSource(current, source) }.exceptionOrNull() !is PaykitAmountError.RateUnavailable + private fun hasPaymentRate(current: SendUiState, source: SendFundingSource): Boolean { + val request = activeIncomingPaymentRequest() ?: return true + return runCatching { request.payment(current.paykitMethod(source), Clock.System.now()) } + .exceptionOrNull() !is PaykitAmountError.RateUnavailable + } private fun reviewPaykitMethodChange(previous: SendUiState): Boolean { if (previous.paykitMethod() == _sendUiState.value.paykitMethod() || diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 87b8c54f93..3d6f552ddf 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -119,6 +119,7 @@ import to.bitkit.ext.toSendFailureDetails import to.bitkit.models.BalanceState import to.bitkit.models.ConvertedAmount import to.bitkit.models.FeeRate +import to.bitkit.models.FxRate import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwWallet import to.bitkit.models.HwWalletReceivedTx @@ -14566,6 +14567,45 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertTrue(sut.sendUiState.value.paykitRateUnavailable) } + @Test + fun `switching from USDT to savings and back restores the entered USDT amount`() = test { + whenever(currencyRepo.currencyState).thenReturn( + MutableStateFlow( + CurrencyState( + rates = persistentListOf( + FxRate( + symbol = "BTCUSD", + lastPrice = "65432.10", + base = "BTC", + baseName = "Bitcoin", + quote = "USD", + quoteName = "US Dollar", + currencySymbol = "$", + currencyFlag = "", + lastUpdatedAt = Clock.System.now().toEpochMilliseconds(), + ), + ), + ), + ), + ) + setSendState( + SendUiState( + address = REGTEST_ADDRESS, + usdtRecipient = "0x1111111111111111111111111111111111111111", + paykitUsesUsdt = true, + paykitAmount = "5", + isAmountInputValid = true, + ) + ) + sut.setSendEvent(SendEvent.PaymentMethodSelect(SendFundingSource.Savings)) + runCurrent() + assertFalse(sut.sendUiState.value.paykitUsesUsdt) + sut.setSendEvent(SendEvent.PaymentMethodSelect(SendFundingSource.Usdt)) + runCurrent() + assertTrue(sut.sendUiState.value.paykitUsesUsdt) + assertEquals(5_000_000uL, PaykitAmount.parseInput(PaykitAsset.USDT, sut.sendUiState.value.paykitAmount).atomic) + } + @Test fun `selecting USDT with an empty amount preserves whole unit entry`() = test { setSendState( From 27adb96c9530c6c43be98fb960781909ab024295 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 28 Oct 2026 15:16:54 +0100 Subject: [PATCH 10/10] fix: keep the fee refresh when pay from has no rate --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 10 +++-- .../viewmodels/AppViewModelSendFlowTest.kt | 44 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 22bf79f3fc..3b4304982f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3995,14 +3995,12 @@ class AppViewModel @Inject constructor( 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 val converted = try { paymentAmountForSource(current, nextSource) } catch (_: PaykitAmountError.RateUnavailable) { if (wasReviewed && !canReviewInPlace) { - // The reviewed quote is still payable, so keep its confirmation and only report the missing rate. + // The reviewed quote is still payable, so keep its confirmation and its fee refresh running. toast( type = Toast.ToastType.WARNING, title = context.getString(R.string.wallet__payment_request_rate_unavailable), @@ -4010,6 +4008,8 @@ class AppViewModel @Inject constructor( ) return } + ++sendStateGeneration + onchainSendRefreshJob?.cancel() _sendUiState.update { it.copy(paykitRateUnavailable = true) } if (canReviewInPlace) { paykitPaymentTerms = null @@ -4018,9 +4018,13 @@ class AppViewModel @Inject constructor( } return } catch (_: PaykitAmountError) { + ++sendStateGeneration + onchainSendRefreshJob?.cancel() _sendUiState.update { it.copy(paykitRateUnavailable = false) } return } + val generation = ++sendStateGeneration + onchainSendRefreshJob?.cancel() _sendUiState.update { it.copy( isFundingSourceLoading = nextSource is SendFundingSource.Hardware, diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index fef8ab4149..037c084fc1 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -15123,6 +15123,50 @@ class AppViewModelSendFlowTest : BaseUnitTest() { ) } + @Test + fun `no-rate pay from toast keeps the confirmation fee refresh running`() = test { + whenever(context.getString(R.string.wallet__payment_request_rate_unavailable)).thenReturn("Rates unavailable") + val finishFeeEstimate = CompletableDeferred() + whenever { lightningRepo.calculateTotalFee(any(), anyOrNull(), any(), anyOrNull(), anyOrNull()) } + .doSuspendableAnswer { + if (sut.currentSheet.value == Sheet.Send(SendRoute.Confirm)) finishFeeEstimate.await() + Result.success(150uL) + } + 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")))), + 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.Confirm), sut.currentSheet.value) + assertTrue(sut.sendUiState.value.onchainFeeUi.isLoading) + + sut.setSendEvent(SendEvent.PaymentMethodSelect(SendFundingSource.Usdt)) + runCurrent() + finishFeeEstimate.complete(Unit) + advanceUntilIdle() + + assertFalse(sut.sendUiState.value.paykitUsesUsdt) + assertFalse(sut.sendUiState.value.onchainFeeUi.isLoading) + assertTrue(sut.sendUiState.value.onchainFeeUi.estimates.isNotEmpty()) + } + @Test fun `contact USDT review without a request keeps the pay from chip disabled`() = test { val bolt11 = "lnbcrt1contactamountless"