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 6d64183220..280461cfe2 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") ) @@ -744,9 +744,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 5edd4a9a8b..72993678ce 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 @@ -32,6 +32,7 @@ import to.bitkit.ui.screens.wallets.send.AutoPayOverlay 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 @@ -83,6 +84,11 @@ fun PaykitUsdtReview( LaunchedEffect(Unit) { preparePayment(viewModel, app, uiState) { automaticPayment = it } } + 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) { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { reconcileSubmittedPayment(viewModel, app) @@ -96,7 +102,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 } @@ -151,6 +157,8 @@ fun PaykitUsdtReview( paymentRequestNote = uiState.paymentRequestNote, isMerchantRequest = uiState.isMerchantPaymentRequest, 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 207c9a6ab1..50235ab6cb 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 @@ -127,6 +128,7 @@ internal fun UsdtPaymentContent( contact: PubkyProfile? = null, onDestinationChange: ((UsdtDestination) -> Unit)? = null, destinations: ImmutableList = Env.usdtDestinations.toImmutableList(), + onSwitchAsset: (() -> Unit)? = null, ) { if (page == UsdtPage.WALLET) return @@ -161,6 +163,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( @@ -188,7 +193,7 @@ internal fun UsdtPaymentContent( showSubmitted && failed -> R.string.wallet__send_error_tx_failed showSubmitted && submissionError != null -> R.string.common__error 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 @@ -222,7 +227,9 @@ internal fun UsdtPaymentContent( onConfirm, completed, onEditAmount = { if (amountEditable) onPageChange(UsdtPage.AMOUNT) }, + onSwitchAsset = onSwitchAsset, ) + preparingConfirmation -> UsdtConfirmationLoading(amount, contact, paymentRequestNote) page == UsdtPage.AMOUNT -> UsdtAmountContent( amount, destination, @@ -560,6 +567,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) } @@ -579,6 +587,7 @@ private fun UsdtConfirmation( isPaymentRequest, isMerchantRequest, showDetails, + onSwitchAsset = onSwitchAsset?.takeUnless { state.busy || completed }, ) { if (!state.busy && !completed) onEditAmount() } @@ -619,6 +628,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( @@ -667,6 +701,7 @@ private fun UsdtReviewHeader( isMerchantRequest: Boolean, showDetails: Boolean, modifier: Modifier = Modifier, + onSwitchAsset: (() -> Unit)? = null, onEditAmount: () -> Unit, ) { Column(modifier = modifier) { @@ -677,7 +712,7 @@ private fun UsdtReviewHeader( ) VerticalSpacer(24.dp) if (showDetails) { - UsdtQuoteDetails(quote, contact, isMerchantRequest) + UsdtQuoteDetails(quote, contact, isMerchantRequest, onSwitchAsset) note?.let { VerticalSpacer(16.dp) PaymentRequestInvoiceNote(it) @@ -690,16 +725,23 @@ private fun UsdtReviewHeader( } @Composable -private fun UsdtQuoteDetails(quote: UsdtQuote, contact: PubkyProfile?, isMerchantRequest: Boolean) { +private fun UsdtQuoteDetails( + quote: UsdtQuote, + contact: PubkyProfile?, + isMerchantRequest: Boolean, + 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 085326d961..dc8b9e6637 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 @@ -258,6 +258,21 @@ 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 + + /** 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 || 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 } + } + fun retryRecovery() = viewModelScope.launch { val current = state.value val quote = current.quote ?: return@launch @@ -290,6 +305,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/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 6d0edaf3f2..6bb5641536 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -120,8 +120,9 @@ 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) { + // A Pay From switch may have replaced the starting confirmation, so clear whichever one is there. + popUpTo(graph.id) } } } @@ -283,6 +284,14 @@ fun SendSheet( is SendEffect.NavigateToCoinSelection -> navController.navigateToCoinSelection() is SendEffect.NavigateToConfirm -> navController.navigateTo(SendRoute.Confirm) is SendEffect.NavigateToUsdtReview -> navController.navigateTo(SendRoute.UsdtReview) + is SendEffect.ShowPaykitConfirmation -> { + // 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) is SendEffect.PaymentSuccess -> { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 38613f0993..3b4304982f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -3782,7 +3782,7 @@ class AppViewModel @Inject constructor( paykitPaymentTerms = initialPayment _sendUiState.update { it.copy(requiresAmountReview = false) } navigateToSendRoute(fromMainScanner, SendRoute.UsdtReview, SendEffect.NavigateToUsdtReview) - } else if (!openBitcoinRequestConfirmation(fromMainScanner)) { + } else if (!openPaykitRequestConfirmation(fromMainScanner)) { navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount) } } @@ -3796,8 +3796,19 @@ class AppViewModel @Inject constructor( return state.shouldAutomaticallyPay && payment == paykitPaymentTerms && payment.amount == paykitReviewedAmount } - 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() @@ -3805,6 +3816,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 @@ -3948,12 +3994,22 @@ class AppViewModel @Inject constructor( if (current.isRetainedHardwarePayment) return val nextSource = nextFundingSource(current, requested) ?: return val canReviewInPlace = canRebindBitcoinRequestTerms(current, nextSource) - val generation = ++sendStateGeneration - onchainSendRefreshJob?.cancel() + val wasReviewed = paykitReviewedAmount != null 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 its fee refresh running. + toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.wallet__payment_request_rate_unavailable), + testTag = "PaykitRateUnavailableToast", + ) + return + } + ++sendStateGeneration + onchainSendRefreshJob?.cancel() _sendUiState.update { it.copy(paykitRateUnavailable = true) } if (canReviewInPlace) { paykitPaymentTerms = null @@ -3962,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, @@ -3992,10 +4052,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 @@ -4010,10 +4075,18 @@ 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 } + 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 { + 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 { @@ -4202,7 +4275,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 @@ -4244,7 +4317,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 } @@ -8357,6 +8430,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() = when { + incomingPaymentRequestId != null && usdtRecipient != null -> fundingOptions.count { it.enabled } > 1 + paykitUsesUsdt -> false + else -> canSwitchBitcoinFundingSource + } + val fixedLightningAmount: ULong? get() = decodedInvoice?.amountSatoshis?.takeIf { it > 0u && !paykitUsesUsdt && payMethod == SendMethod.LIGHTNING && lnurl == null @@ -8463,6 +8544,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-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 5dd6fa64f2..ff209efb6b 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -749,6 +749,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 0a74f53f1c..69cc877953 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -772,6 +772,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 e2374f0152..2bd203424c 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -766,6 +766,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 4e1f55b3ff..6705941593 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -770,6 +770,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 8b510f02f2..eed0ddd397 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -768,6 +768,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 2c3828250a..dbebe55087 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -750,6 +750,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 069ce4038c..f0da5fc392 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -768,6 +768,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 23d0b20f08..085e8586c6 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -765,6 +765,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 f3b50ffb40..319f835cf7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -769,6 +769,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 54dae5e261..37909915f2 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -768,6 +768,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 0feea1d35d..27f6ff0fcc 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -764,6 +764,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 ceb333e31c..f1c4ff0c0b 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -771,6 +771,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 30a3594975..964754fd67 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -772,6 +772,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 4161327225..e64142e327 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -961,6 +961,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 ad0e33a86d..c76bd00d71 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -785,6 +785,7 @@ Не удалось оплатить комиссию за транзакцию в USDT. Попробуйте меньшую сумму. Не удалось обновить USDT. Проверьте подключение и попробуйте ещё раз. Платёж всё ещё в ожидании. Дождитесь подтверждения, прежде чем отправлять снова. + Расчёт комиссии устарел и был обновлён. Проверьте комиссию и проведите ещё раз. Слишком много запросов. Попробуйте ещё раз чуть позже. Сеть USDT отклонила запрос. Попробуйте ещё раз. Сервис USDT вернул некорректный ответ. Попробуйте позже. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 00706c45a0..93d405a759 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1217,6 +1217,7 @@ 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. 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 faafff36c3..7466c3ea54 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 @@ -45,6 +45,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 @@ -481,6 +482,77 @@ 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 `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 `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() + 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 { // A previously acknowledged payment must not clear this payment's failed backup. 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 60237ad8c2..037c084fc1 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 @@ -14573,6 +14574,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( @@ -14781,7 +14821,57 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `fixed dollar request keeps asset selection only when USDT is enabled`() = 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) val usdt = Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", rawPayload = "") val endpoints = listOf(bitcoin, usdt) @@ -14807,7 +14897,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) @@ -14909,7 +14999,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) @@ -14933,14 +15023,204 @@ 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) + 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 `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) + 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) + + clearInvocations(toastManager) + sut.sendEffect.test { + sut.setSendEvent(SendEvent.PaymentMethodSelect(SendFundingSource.Usdt)) + advanceUntilIdle() + 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 + 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" + 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 = "") + 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 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.