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..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 @@ -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) { @@ -81,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 } @@ -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..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,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 } + } + private data class PreparedQuote(val quote: UsdtQuote, val amount: String, val sender: String) private fun send(quote: UsdtQuote) = viewModelScope.launch { @@ -253,6 +268,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 132d87ae4b..1d9f38760b 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,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 -> { @@ -349,7 +357,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..f116464611 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() = 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 @@ -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..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 @@ -366,6 +367,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 { 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..f138260b0b 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,14 +14247,117 @@ 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 `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.