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 6aefa6d5b0..663860ee52 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 @@ -122,6 +122,7 @@ internal fun UsdtPaymentContent( onMaximum: (() -> Unit)? = null, hideBalance: Boolean = false, amountEditable: Boolean = true, + isScannedAmount: Boolean = false, isPaymentRequest: Boolean = false, paymentRequestNote: String? = null, isMerchantRequest: Boolean = false, @@ -163,8 +164,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 + // A fixed amount (a payment request, or a scanned or linked one) waits for its quote on a confirm screen. + val preparingConfirmation = quote == null && page == UsdtPage.AMOUNT && !amountEditable && + (isPaymentRequest || isScannedAmount) if (showSuccess && completed && quote != null) { NewTransactionSheetView( details = NewTransactionSheetDetails( @@ -193,7 +195,7 @@ internal fun UsdtPaymentContent( showSubmitted && submissionError != null -> R.string.common__error showSubmitted -> R.string.usdt__submitted (quote != null || preparingConfirmation) && isPaymentRequest -> R.string.wallet__payment_request - quote != null -> R.string.wallet__send_review + quote != null || preparingConfirmation -> R.string.wallet__send_review page == UsdtPage.AMOUNT -> R.string.usdt__amount else -> R.string.usdt__send_title } @@ -235,6 +237,7 @@ internal fun UsdtPaymentContent( state, fundingSource, onReview, + isPaymentRequest, ) page == UsdtPage.AMOUNT -> UsdtAmountContent( amount, @@ -643,6 +646,7 @@ private fun UsdtConfirmationLoading( state: UsdtSendState, fundingSource: (@Composable () -> Unit)?, onRetry: () -> Unit, + isPaymentRequest: Boolean, ) { Column(modifier = Modifier.fillMaxSize().testTag("UsdtConfirmLoading")) { UsdtAmountHeader(amount.ifEmpty { "0" }) @@ -651,14 +655,24 @@ private fun UsdtConfirmationLoading( Box(modifier = Modifier.align(Alignment.End)) { fundingSource() } } VerticalSpacer(24.dp) - PaymentRequestSummary( - profile = contact, - note = note?.trim()?.takeIf { it.isNotEmpty() }, - accentColor = Colors.Usdt, + if (isPaymentRequest) { + PaymentRequestSummary( + profile = contact, + note = note?.trim()?.takeIf { it.isNotEmpty() }, + accentColor = Colors.Usdt, + ) + } + PaymentReviewIllustration( + swipeProgress = { 0f }, + isPaymentRequest = isPaymentRequest, + modifier = Modifier.weight(1f), ) - PaymentReviewIllustration(swipeProgress = { 0f }, isPaymentRequest = true, modifier = Modifier.weight(1f)) state.error?.let { - BodyM(state.errorMessage(it), color = Colors.Brand) + BodyS(state.errorMessage(it), color = Colors.Brand, modifier = Modifier.testTag("UsdtError")) + VerticalSpacer(16.dp) + } + // Also offered without an error: a review restored after process death has no quote in flight. + if (state.error != null || !state.busy) { PrimaryButton( text = stringResource(R.string.common__try_again), onClick = onRetry, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt index e6fb2d2460..2629ad5cae 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt @@ -54,6 +54,8 @@ import to.bitkit.R import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType +import to.bitkit.models.PaykitAmount +import to.bitkit.models.PaykitAsset import to.bitkit.repositories.UsdtWalletState import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BiometricsView @@ -132,9 +134,20 @@ fun UsdtWalletScreen( var selectedTransferId by rememberSaveable(initialTransferId) { mutableStateOf(initialTransferId) } var recipient by rememberSaveable { mutableStateOf(initialRecipient) } var amount by rememberSaveable { mutableStateOf(initialAmount) } + // The recipient whose scanned or linked request named the amount; the payee set it, so it cannot be edited. + var fixedAmountRecipient by rememberSaveable { + mutableStateOf(initialRecipient.takeIf { isPositiveUsdtAmount(initialAmount) }) + } + val isAmountFixed = fixedAmountRecipient != null var maximumAmount by rememberSaveable { mutableStateOf(null) } val destinations by viewModel.destinations.collectAsStateWithLifecycle() var destination by rememberSaveable { mutableStateOf(UsdtDestination.ARBITRUM) } + InitialUsdtQuoteEffect( + payment = InitialUsdtPayment(page, recipient, amount, destination, hasQuote = state.quote != null), + initialRecipient = initialRecipient, + initialAmount = initialAmount, + onQuote = { viewModel.quote(recipient, amount, destination) }, + ) val dismissalBlocked = state.busy || state.authenticationRequired val dismiss = { if (!dismissalBlocked) { @@ -147,7 +160,11 @@ fun UsdtWalletScreen( state.busy -> Unit state.authenticationRequired -> viewModel.cancelAuthentication() state.submitted -> dismiss() - state.quote != null -> viewModel.edit() + state.quote != null -> { + viewModel.edit() + // Amount cannot change a fixed amount, so Back leaves it as well. + if (isAmountFixed) page = UsdtPage.MANUAL + } page in listOf(UsdtPage.AMOUNT, UsdtPage.MANUAL) -> { viewModel.edit() page = if (page == UsdtPage.AMOUNT) UsdtPage.MANUAL else UsdtPage.RECIPIENT @@ -191,6 +208,7 @@ fun UsdtWalletScreen( app?.requestUsdtEnable { recipient = "" amount = "" + fixedAmountRecipient = null viewModel.edit() page = UsdtPage.RECIPIENT } @@ -267,9 +285,13 @@ fun UsdtWalletScreen( onRecipientContinue = { value -> viewModel.validateRecipient(value, destination) { request -> if (request.chainId != null) destination = UsdtDestination.ARBITRUM + val requestAmount = request.amount?.let { usdtFormatAmount(it) } + fixedAmountRecipient = + fixedUsdtAmountRecipient(request.recipient, requestAmount, fixedAmountRecipient) recipient = request.recipient - amount = request.amount?.let { usdtFormatAmount(it) } ?: amount + amount = requestAmount ?: amount page = UsdtPage.AMOUNT + if (fixedAmountRecipient != null) viewModel.quote(recipient, amount, destination) } }, onPageChange = { @@ -288,6 +310,8 @@ fun UsdtWalletScreen( onConfirm = { viewModel.confirm() }, onRetryRecovery = { viewModel.retryRecovery() }, onDone = dismiss, hideBalance = settings.hideBalance, + amountEditable = !isAmountFixed, + isScannedAmount = isAmountFixed, onDetails = { selectedTransferId = state.quote?.id dismiss() @@ -299,6 +323,50 @@ fun UsdtWalletScreen( } } +@Composable +private fun InitialUsdtQuoteEffect( + payment: InitialUsdtPayment, + initialRecipient: String, + initialAmount: String, + onQuote: () -> Unit, +) { + var isRequested by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + if (isRequested) return@LaunchedEffect + isRequested = true + val isInitialPayment = payment.page == UsdtPage.AMOUNT && payment.recipient == initialRecipient && + payment.amount == initialAmount && payment.destination == UsdtDestination.ARBITRUM && !payment.hasQuote + if (isInitialPayment && shouldQuoteInitialUsdtPayment(initialRecipient, initialAmount)) onQuote() + } +} + +private data class InitialUsdtPayment( + val page: UsdtPage, + val recipient: String, + val amount: String, + val destination: UsdtDestination, + val hasQuote: Boolean, +) + +/** A payment request with a recipient and a positive amount opens on its review, as tapping Review would. */ +internal fun shouldQuoteInitialUsdtPayment(recipient: String, amount: String): Boolean = + recipient.isNotEmpty() && isPositiveUsdtAmount(amount) + +internal fun isPositiveUsdtAmount(amount: String): Boolean = + runCatching { PaykitAmount.parseInput(PaykitAsset.USDT, amount) }.isSuccess + +/** + * The recipient whose request fixes the amount. A positive requested amount fixes it for that recipient. + * Continuing with the same recipient and no amount, as after Back from review, keeps it; any other recipient + * clears it. + */ +internal fun fixedUsdtAmountRecipient(recipient: String, requestAmount: String?, fixedRecipient: String?): String? = + when { + requestAmount != null -> recipient.takeIf { isPositiveUsdtAmount(requestAmount) } + fixedRecipient.equals(recipient, ignoreCase = true) -> fixedRecipient + else -> null + } + @Composable internal fun rememberUsdtPage(initialRecipient: String, state: UsdtSendState): MutableState { val paymentInProgress = state.submitted || (state.busy && state.quote != null) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 95bcf05879..03deb4ee93 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4161,7 +4161,7 @@ class AppViewModel @Inject constructor( } private suspend fun onAmountChange(amount: ULong) { - if (_sendUiState.value.paykitUsesUsdt || _sendUiState.value.fixedLightningAmount != null) return + if (_sendUiState.value.paykitUsesUsdt || !_sendUiState.value.isAmountEditable) return _sendUiState.update { it.copy( amount = amount, @@ -4216,7 +4216,8 @@ class AppViewModel @Inject constructor( else -> false } - val wasHardwareMax = previous.hardwareWalletId != null && + // A scanned amount is the payee's, even when it equals the hardware maximum. + val wasHardwareMax = previous.hardwareWalletId != null && previous.fixedOnchainAmount == null && previous.amount > 0uL && previous.amount == previous.hardwareAvailableSats val hardwareAvailableSats = previous.hardwareWalletId?.let { walletId -> @@ -4372,6 +4373,9 @@ class AppViewModel @Inject constructor( fixedContactLightningAmount(current)?.let { return PaykitAmount(PaykitAsset.BTC, it) } + } else if (source != SendFundingSource.Usdt) { + // An amount typed on Spending must not replace the payee's on-chain amount. + current.fixedOnchainAmount?.let { return PaykitAmount(PaykitAsset.BTC, it) } } val input = activeIncomingPaymentRequest()?.amount ?: if (current.paykitUsesUsdt) { runCatching { PaykitAmount.parseInput(PaykitAsset.USDT, current.paykitAmount) }.getOrNull() @@ -5266,6 +5270,7 @@ class AppViewModel @Inject constructor( }, hardwareWalletId = hardwareWalletId, hardwareAvailableSats = selectedMaxSendOnchain.takeIf { hardwareWalletId != null } ?: 0uL, + fixedOnchainAmount = invoice.amountSatoshis.takeIf { it > 0uL && incomingPaymentRequest == null }, ) } updateCanSwitchWallet() @@ -5399,6 +5404,8 @@ class AppViewModel @Inject constructor( return } + if (amount > 0u && routeOnchainInvoiceAmount(amount, fromMainScanner, walletGeneration)) return + Logger.info( when (amount > 0u) { true -> "Found amount in invoice, proceeding to edit amount" @@ -5410,6 +5417,43 @@ class AppViewModel @Inject constructor( navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount, walletGeneration) } + /** + * The payee set the amount, so it opens Confirm and never Amount. Returns false only when the + * request is paid another way. Manual coin selection opens the picker from the swipe. + */ + private suspend fun routeOnchainInvoiceAmount( + amount: ULong, + fromMainScanner: Boolean, + walletGeneration: Long?, + ): Boolean { + if (_sendUiState.value.payMethod != SendMethod.ONCHAIN || hasActiveContactPaymentContext()) return false + if (amount <= Defaults.dustLimit.toULong()) { + toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.wallet__lnurl_pay__error_min__title), + description = context.getString(R.string.wallet__lnurl_pay__error_min__description) + .replace("{amount}", formatMoneyValue(Defaults.dustLimit.toULong().safe() + 1uL.safe())), + testTag = "AmountTooLowToast", + ) + return true + } + // Savings cannot cover it but a hardware wallet can, so pay from that instead of opening Amount. + if (!validateAmount(amount) && _sendUiState.value.hardwareWalletId == null) { + selectHardwareFundingSourceForAmount(amount, walletGeneration) + } + if (!validateAmount(amount)) return false + requireCurrentPaymentPreparation(walletGeneration) + // A failed estimate has shown its error; the user stays where the scan started. + if (!prepareHardwareSendFee()) return true + requireCurrentPaymentPreparation(walletGeneration) + + Logger.info("Found payable amount in invoice, proceeding to confirm", context = TAG) + _sendUiState.update { it.copy(isAmountInputValid = true) } + navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm, walletGeneration) + refreshOnchainSendIfNeeded(walletGeneration) + return true + } + private suspend fun hardwareMaxSpendable( walletId: String, address: String, @@ -8935,6 +8979,8 @@ data class SendUiState( val isInitialSubscriptionPayment: Boolean = false, val initialSubscriptionPaymentAutoStartPending: Boolean = false, val requiresAmountReview: Boolean = false, + /** The amount of a scanned on-chain request; the payee set it, so it cannot be edited. */ + val fixedOnchainAmount: ULong? = null, val initialSubscriptionPaymentNeedsConfirmation: Boolean = false, val incomingPaymentRequestId: PaykitPaymentRequestId? = null, ) { @@ -8958,7 +9004,8 @@ data class SendUiState( get() = hardwareWalletId == null || (!onchainFeeUi.isLoading && !onchainFeeUi.requiresRefresh) val isAmountEditable: Boolean - get() = !isPaymentRequest && fixedLightningAmount == null + get() = !isPaymentRequest && fixedLightningAmount == null && + !(payMethod == SendMethod.ONCHAIN && fixedOnchainAmount != null) val shouldAutomaticallyPay: Boolean get() = isInitialSubscriptionPayment && hardwareWalletId == null && diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreenTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreenTest.kt new file mode 100644 index 0000000000..6ebb84303c --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreenTest.kt @@ -0,0 +1,52 @@ +package to.bitkit.ui.screens.wallets.usdt + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class UsdtWalletScreenTest { + private val recipient = "0x1111111111111111111111111111111111111111" + + @Test + fun `initial payment with recipient and positive amount is quoted`() { + assertTrue(shouldQuoteInitialUsdtPayment(recipient, "0.010001")) + assertTrue(shouldQuoteInitialUsdtPayment(recipient, "5")) + } + + @Test + fun `initial payment without a payable amount is not quoted`() { + assertFalse(shouldQuoteInitialUsdtPayment(recipient, "")) + assertFalse(shouldQuoteInitialUsdtPayment(recipient, "0")) + assertFalse(shouldQuoteInitialUsdtPayment(recipient, "abc")) + assertFalse(shouldQuoteInitialUsdtPayment(recipient, "0.0000001")) + } + + @Test + fun `initial payment without recipient is not quoted`() { + assertFalse(shouldQuoteInitialUsdtPayment("", "5")) + } + + private val otherRecipient = "0x2222222222222222222222222222222222222222" + + @Test + fun `a positive requested amount is fixed and zero is not`() { + assertEquals(recipient, fixedUsdtAmountRecipient(recipient, requestAmount = "0.010001", fixedRecipient = null)) + assertNull(fixedUsdtAmountRecipient(recipient, requestAmount = "0", fixedRecipient = recipient)) + assertFalse(isPositiveUsdtAmount("0")) + } + + @Test + fun `continuing with the recipient that fixed the amount keeps it`() { + assertEquals(recipient, fixedUsdtAmountRecipient(recipient, requestAmount = null, fixedRecipient = recipient)) + val checksummed = recipient.uppercase().replace("0X", "0x") + assertEquals(recipient, fixedUsdtAmountRecipient(checksummed, requestAmount = null, fixedRecipient = recipient)) + } + + @Test + fun `another recipient or no earlier fixed amount leaves the amount editable`() { + assertNull(fixedUsdtAmountRecipient(otherRecipient, requestAmount = null, fixedRecipient = recipient)) + assertNull(fixedUsdtAmountRecipient(recipient, requestAmount = null, fixedRecipient = null)) + } +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 89de855cf8..4f6903cce9 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -118,6 +118,7 @@ import to.bitkit.data.keychain.hasPendingWalletRestore import to.bitkit.domain.commands.NotifyChannelReadyHandler import to.bitkit.domain.commands.NotifyPaymentReceived import to.bitkit.domain.commands.NotifyPaymentReceivedHandler +import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.callbackAmountMsats import to.bitkit.ext.fromHex @@ -6454,6 +6455,19 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertFalse(sut.sendUiState.value.canSwitchWallet) } + @Test + fun `switching to savings restores the scanned onchain amount over a typed one`() = test { + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u, maxSendLightningSats = 100_000u) + setUnifiedState(amount = 5_000u, payMethod = SendMethod.LIGHTNING) + setSendState(sut.sendUiState.value.copy(fixedOnchainAmount = 10_000u)) + + sut.setSendEvent(SendEvent.PaymentMethodSwitch) + advanceUntilIdle() + + assertEquals(SendMethod.ONCHAIN, sut.sendUiState.value.payMethod) + assertEquals(10_000uL, sut.sendUiState.value.amount) + } + @Test fun `swipe after switching to savings opens manual coin selection`() = test { settingsData.value = SettingsData(coinSelectAuto = false) @@ -9329,6 +9343,177 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Amount), sut.currentSheet.value) } + @Test + fun `main scanner onchain scan with payable amount opens confirm sheet`() = test { + val input = stubOnchainAmountScan(amountSats = 10_000u) + + sut.showScannerSheet() + sut.onScannerSheetResult(input) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + val state = sut.sendUiState.value + assertEquals(10_000uL, state.amount) + assertEquals(SendMethod.ONCHAIN, state.payMethod) + assertTrue(state.isAmountInputValid) + assertFalse(state.isAmountEditable) + } + + @Test + fun `main scanner onchain scan without amount opens amount sheet`() = test { + val input = stubOnchainAmountScan(amountSats = 0u) + + sut.showScannerSheet() + sut.onScannerSheetResult(input) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Amount), sut.currentSheet.value) + assertTrue(sut.sendUiState.value.isAmountEditable) + } + + @Test + fun `main scanner onchain scan with dust amount shows the minimum without opening send`() = test { + val input = stubOnchainAmountScan(amountSats = Defaults.dustLimit.toULong()) + whenever(formatMoneyValue(any())).thenReturn("547 sats") + + sut.showScannerSheet() + sut.onScannerSheetResult(input) + advanceUntilIdle() + + assertFalse(sut.currentSheet.value is Sheet.Send) + val toasts = argumentCaptor() + verify(toastManager, atLeast(1)).enqueue(toasts.capture()) + assertTrue(toasts.allValues.any { it.testTag == "AmountTooLowToast" }) + } + + @Test + fun `main scanner onchain scan with amount opens confirm sheet for manual coin selection`() = test { + settingsData.update { it.copy(coinSelectAuto = false) } + val input = stubOnchainAmountScan(amountSats = 10_000u) + + sut.showScannerSheet() + sut.onScannerSheetResult(input) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(10_000uL, sut.sendUiState.value.amount) + assertFalse(sut.sendUiState.value.isAmountEditable) + + sut.setSendEvent(SendEvent.AmountChange(5_000u)) + advanceUntilIdle() + + assertEquals(10_000uL, sut.sendUiState.value.amount) + } + + @Test + fun `send sheet onchain address with payable amount navigates to confirm`() = test { + val input = stubOnchainAmountScan(amountSats = 10_000u) + + sut.showSheet(Sheet.Send()) + advanceUntilIdle() + sut.sendEffect.test { + sut.setSendEvent(SendEvent.AddressContinue(input)) + advanceUntilIdle() + + assertEquals(SendEffect.NavigateToConfirm, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + assertFalse(sut.sendUiState.value.isAmountEditable) + } + + @Test + fun `hardware send sheet onchain address with payable amount estimates its fee and opens confirm`() = test { + val input = stubOnchainAmountScan(amountSats = 10_000u) + stubHardwareFunding() + whenever(hwWalletRepo.estimateFundingMiningFee(any(), any(), any(), any())).thenReturn(Result.success(250uL)) + + sut.showSheet(Sheet.Send(hardwareWalletId = HARDWARE_WALLET_ID)) + advanceUntilIdle() + sut.sendEffect.test { + sut.setSendEvent(SendEvent.AddressContinue(input)) + advanceUntilIdle() + + assertEquals(SendEffect.NavigateToConfirm, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + verify(hwWalletRepo).estimateFundingMiningFee(eq(HARDWARE_WALLET_ID), eq(REGTEST_ADDRESS), eq(10_000uL), any()) + assertEquals(HARDWARE_WALLET_ID, sut.sendUiState.value.hardwareWalletId) + assertFalse(sut.sendUiState.value.isAmountEditable) + } + + @Test + fun `hardware send sheet onchain address stays on the address when its fee estimate fails`() = test { + val input = stubOnchainAmountScan(amountSats = 10_000u) + stubHardwareFunding() + whenever(hwWalletRepo.estimateFundingMiningFee(any(), any(), any(), any())) + .thenReturn(Result.failure(Exception("fee estimate failed"))) + + sut.showSheet(Sheet.Send(hardwareWalletId = HARDWARE_WALLET_ID)) + advanceUntilIdle() + sut.sendEffect.test { + sut.setSendEvent(SendEvent.AddressContinue(input)) + advanceUntilIdle() + + expectNoEvents() + } + val toasts = argumentCaptor() + verify(toastManager, atLeast(1)).enqueue(toasts.capture()) + assertTrue(toasts.allValues.isNotEmpty()) + } + + @Test + fun `main scanner onchain scan only a hardware wallet covers opens confirm on it`() = test { + val input = stubOnchainAmountScan(amountSats = 20_000u) + balanceState.value = BalanceState(maxSendOnchainSats = 5_000u) + stubHardwareFunding() + whenever(hwWalletRepo.estimateFundingMiningFee(any(), any(), any(), any())).thenReturn(Result.success(250uL)) + + sut.showScannerSheet() + sut.onScannerSheetResult(input) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + assertEquals(HARDWARE_WALLET_ID, sut.sendUiState.value.hardwareWalletId) + assertEquals(20_000uL, sut.sendUiState.value.amount) + } + + @Test + fun `speed change keeps a scanned hardware amount that equals the hardware maximum`() = test { + val input = stubOnchainAmountScan(amountSats = 48_000u) + stubHardwareFunding() + whenever(hwWalletRepo.estimateFundingMiningFee(any(), any(), any(), any())).thenReturn(Result.success(250uL)) + sut.showSheet(Sheet.Send(hardwareWalletId = HARDWARE_WALLET_ID)) + advanceUntilIdle() + sut.setSendEvent(SendEvent.AddressContinue(input)) + advanceUntilIdle() + assertEquals(48_000uL, sut.sendUiState.value.hardwareAvailableSats) + + whenever { hwWalletRepo.maxSpendableFunding(any(), any(), any()) }.thenReturn(Result.success(46_000uL)) + sut.setTransactionSpeed(TransactionSpeed.Fast) + advanceUntilIdle() + + assertEquals(46_000uL, sut.sendUiState.value.hardwareAvailableSats) + assertEquals(48_000uL, sut.sendUiState.value.amount) + } + + private fun stubHardwareFunding() { + hwWallets.value = persistentListOf(hardwareWallet(fundingBalanceSats = 50_000uL)) + whenever { hwWalletRepo.maxSpendableFunding(any(), any(), any()) }.thenReturn(Result.success(48_000uL)) + } + + private suspend fun stubOnchainAmountScan(amountSats: ULong): String { + whenever(coreService.validateBitcoinAddress(REGTEST_ADDRESS)).thenReturn( + ValidationResult(REGTEST_ADDRESS, NetworkType.REGTEST, AddressType.P2WPKH), + ) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + sut.setIsAuthenticated(true) + val input = "bitcoin:$REGTEST_ADDRESS?amount=$amountSats" + whenever(coreService.decode(input)).thenReturn( + Scanner.OnChain(OnChainInvoice(REGTEST_ADDRESS, amountSats, null, null, null)), + ) + return input + } + @Test fun `onchain validation mismatch is silent after wallet replacement`() = test { val input = "bitcoin:$REGTEST_ADDRESS?amount=0.0001" @@ -16732,6 +16917,47 @@ class AppViewModelSendFlowTest : BaseUnitTest() { verify(paykitPaymentProofRepo, never()).prepare(any(), any(), any(), any(), anyOrNull(), anyOrNull()) } + @Test + fun `fixed dollar request accepting USDT opens bitcoin confirmation while USDT is off`() = test { + enablePaykitUi() + usdtEnabled.value = false + pubkyPublicKey.value = testPublicKey + sut.setIsAuthenticated(true) + runCurrent() + val endpoints = listOf( + Endpoint(MethodId.P2wpkh, REGTEST_ADDRESS, rawPayload = REGTEST_ADDRESS), + Endpoint(MethodId.UsdtArbitrum, "0x1111111111111111111111111111111111111111", rawPayload = ""), + ) + val request = paymentRequest().copy( + amount = PaykitAmount(PaykitAsset.USD, 250u), + pricing = PaykitRequestPricing( + PaymentConversion.Fixed(listOf(ConversionRate("btc", "0.00001"), ConversionRate("usdt", "1"))), + ), + acceptedPaymentEndpointIdentifiers = endpoints.map { it.methodId.rawValue }, + ) + balanceState.value = BalanceState(maxSendOnchainSats = 100_000u) + whenever(coreService.decode(REGTEST_ADDRESS)).thenReturn( + Scanner.OnChain(OnChainInvoice(REGTEST_ADDRESS, 0u, null, null, null)), + ) + val resolution = CompletableDeferred>() + whenever(privatePaykitRepo.beginPaymentRequest(request)).doSuspendableAnswer { resolution.await() } + pendingPaykitPaymentRequests.value = listOf(request) + runCurrent() + assertEquals(request, (sut.currentSheet.value as? Sheet.Send)?.preparingRequest) + resolution.complete( + Result.success(PublicPaykitPaymentResult.Opened(REGTEST_ADDRESS, endpoints = endpoints)) + ) + runCurrent() + + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) + val state = sut.sendUiState.value + assertFalse(state.paykitUsesUsdt) + assertEquals(SendMethod.ONCHAIN, state.payMethod) + assertEquals(2_500uL, state.amount) + assertTrue(state.isAmountInputValid) + assertFalse(state.shouldConfirmPay) + } + @Test fun `quoted Bitcoin wallet switches stay on confirmation and bind the new amount`() = test { usdtEnabled.value = false diff --git a/changelog.d/next/1763.fixed.md b/changelog.d/next/1763.fixed.md new file mode 100644 index 0000000000..eb7c18b264 --- /dev/null +++ b/changelog.d/next/1763.fixed.md @@ -0,0 +1 @@ +Scanning a Bitcoin or USDT payment request that already has an amount now opens the review screen directly instead of the amount screen. diff --git a/journeys/boost/rbf-own-send.xml b/journeys/boost/rbf-own-send.xml index c28e37792c..8b533ce09c 100644 --- a/journeys/boost/rbf-own-send.xml +++ b/journeys/boost/rbf-own-send.xml @@ -7,8 +7,8 @@ the configured regtest backend's block-mining helper. - Hand the app the address as a URI with adb shell am start -a android.intent.action.VIEW -d "bitcoin:ADDRESS?amount=0.0001" to.bitkit.dev, which opens the amount screen at 10,000 sats - Verify the amount screen shows 10,000 sats, tap Continue (testTag "ContinueAmount") and verify the review screen (testTag "SendConfirm") is visible + Hand the app the address as a URI with adb shell am start -a android.intent.action.VIEW -d "bitcoin:ADDRESS?amount=0.0001" to.bitkit.dev, which opens the review screen at 10,000 sats + Verify the review screen (title "Confirm") is visible and its amount (testTag "ReviewAmount") shows 10,000 sats Swipe the send handle (testTag "GRAB") fully to the right and wait for the success screen (testTag "SendSuccess"), then tap Close Tap the newest outgoing activity entry (testTag "ActivityShort-0") and verify it shows 10,000 sats; record the fee from testTag "ActivityFee" Tap Transaction details (testTag "ActivityTxDetails"), record the TXID, then go back diff --git a/journeys/coin-selection/manual-coin-selection-load.xml b/journeys/coin-selection/manual-coin-selection-load.xml index 5e6aa4c770..5dbdc49b85 100644 --- a/journeys/coin-selection/manual-coin-selection-load.xml +++ b/journeys/coin-selection/manual-coin-selection-load.xml @@ -25,8 +25,8 @@ Note which method and autopilot mode are checked, then tap Manual (testTag "manual_button") Go back to the wallet home screen Run adb shell am start -a android.intent.action.VIEW -d "bitcoin:<regtest address>?amount=0.0001" to.bitkit.dev - Verify the amount field (testTag "SendNumberField") shows 10 000, waiting through "Connecting to network" (testTag "sync_node_view") if it is shown - Tap Continue (testTag "ContinueAmount") + Verify the Confirm screen (testTag "ReviewAmount") shows 10 000 without the amount field (testTag "SendNumberField") having been shown, waiting through "Connecting to network" (testTag "sync_node_view") if it is shown + Swipe the send handle (testTag "GRAB") fully to the right Verify the coin selection screen (testTag "coin_selection_screen") lists one row per UTXO (testTags starting with "utxo_row_"), all toggled on, with TOTAL REQUIRED and TOTAL SELECTED above zero Verify no error toast appeared and the load error text "Bitkit could not load your coins. Please try again." is not shown Tap the smallest UTXO row once to deselect it diff --git a/journeys/coin-selection/manual-coin-selection.xml b/journeys/coin-selection/manual-coin-selection.xml index ce4de28ac3..95065600a6 100644 --- a/journeys/coin-selection/manual-coin-selection.xml +++ b/journeys/coin-selection/manual-coin-selection.xml @@ -8,13 +8,13 @@ deposits to a Savings address and mine a block. Start on the wallet home screen. The send uses your own Savings address (Settings > Advanced > Address Viewer); nothing is broadcast. - Tapping the amount on the amount screen swaps the primary display unit. Do not tap it. + A link with an amount opens Confirm directly; the swipe opens coin selection. Open Settings, then the "Advanced" tab (testTag "Tab-advanced"), then "Coin Selection" (testTag "CoinSelectPreference") Tap "Manual" (testTag "manual_button") and go back to the home screen Run `adb shell am start -a android.intent.action.VIEW -d "bitcoin:<own savings address>?amount=0.0001" to.bitkit.dev` - Verify the amount screen shows 10 000 sats and tap Continue (testTag "ContinueAmount") + Verify the Confirm screen (testTag "ReviewAmount") shows 10 000 sats without the amount screen (testTag "SendNumberField") having been shown, then swipe the send handle (testTag "GRAB") fully to the right Verify the coin selection screen (testTag "coin_selection_screen") is visible Verify there is no element with testTag "auto_select_row" and no row labelled "Auto" Verify one row per UTXO (testTag prefix "utxo_row_") is listed, largest value first, and every switch is on (screenshot) diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 00b162d346..548bf24d3c 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -5,8 +5,8 @@ Cover incoming Paykit Payment Requests from a linked issuer. The issuer contract [`app/src/test/resources/paykit-issuer-interoperability.json`](../../app/src/test/resources/paykit-issuer-interoperability.json). `fixed-price-bitcoin.xml` verifies a USD-denominated request settled in BTC and an explicit -same-asset rail price. After preparation, the Amount screen shows the fixed, uneditable payment -amount with Savings selected. Continue opens confirmation with the same amount. The amount uses +same-asset rail price. After preparation, confirmation opens directly with the fixed, uneditable +payment amount, without an Amount screen in between. The amount uses the issuer's fixed rate; the displayed fiat estimate uses Bitkit's own market rate. The flow stops before broadcasting. Differing rail prices, fractional-satoshi Lightning payments, and dynamic quotes remain unsupported. diff --git a/journeys/payment-requests/fixed-price-bitcoin.xml b/journeys/payment-requests/fixed-price-bitcoin.xml index 138c2a1cae..b5f842d551 100644 --- a/journeys/payment-requests/fixed-price-bitcoin.xml +++ b/journeys/payment-requests/fixed-price-bitcoin.xml @@ -6,18 +6,16 @@ Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer saved as a contact and linked as identities Have the issuer publish a current regtest P2WPKH address under identifier "btc-regtest-p2wpkh" with JSON payload {"value":"<current address>"} Have the issuer send a new one-time request for amount "10", asset "usd", accepted identifier "btc-regtest-p2wpkh", and fixed conversion rate "btc" equal to "0.000021" - Wait for request preparation to finish and the Amount screen to open with Savings selected + Wait for request preparation to finish and verify the Payment Request confirmation screen (id "PaymentRequestConfirm") opens directly, without the Amount screen (id "SendNumberField") or its keypad being shown Switch the amount display to Bitcoin if needed - Verify the requested amount is 21,000 sats, cannot be edited, and does not show 10 BTC - Tap Continue and verify the Payment Request confirmation screen (id "PaymentRequestConfirm") shows 21,000 sats + Verify the confirmation amount (id "ReviewAmount") is 21,000 sats, cannot be edited, and does not show 10 BTC Wait for fresh validation and wallet preparation to finish; verify the same sheet stays open and its payment swipe control (id "GRAB") stops showing progress Verify the fiat estimate equals Bitkit's current local valuation of 21,000 sats, rather than being forced to the issuer's 10 USD denomination Dismiss the Payment Request confirmation screen without paying Have the issuer send a new one-time request for amount "0.001", asset "btc", accepted identifier "btc-regtest-p2wpkh", and fixed rates "btc" equal to "2" and "btc-regtest" equal to "0.5" - Wait for request preparation to finish and the Amount screen to open with Savings selected + Wait for request preparation to finish and verify the Payment Request confirmation screen (id "PaymentRequestConfirm") opens directly, without the Amount screen (id "SendNumberField") being shown Switch the amount display to Bitcoin if needed - Verify the requested amount is 50,000 sats, cannot be edited, and does not show 100,000 or 200,000 sats - Tap Continue and verify the Payment Request confirmation screen (id "PaymentRequestConfirm") still shows 50,000 sats + Verify the confirmation amount (id "ReviewAmount") is 50,000 sats, cannot be edited, and does not show 100,000 or 200,000 sats Dismiss the Payment Request confirmation screen without paying diff --git a/journeys/payment-requests/fixed-quoted-confirmation.xml b/journeys/payment-requests/fixed-quoted-confirmation.xml index b5bfa029bc..fe4a0e0294 100644 --- a/journeys/payment-requests/fixed-quoted-confirmation.xml +++ b/journeys/payment-requests/fixed-quoted-confirmation.xml @@ -3,6 +3,7 @@ Disable USDT on the payer. Create a fixed 2.50 USD request accepting Bitcoin, then open it on the payer. Verify Preparing changes directly to Confirm without showing a disabled Amount keypad. Verify the exact BTC amount follows the request quote, the fiat equivalent uses the payer wallet rate, and the fee is shown before payment is enabled. + With USDT still disabled, open a fixed 2.50 USD request accepting both Bitcoin and USDT. Verify Preparing again changes directly to the Bitcoin Confirm without showing the Amount screen or an Enable USDT prompt. Close without paying. Choose a non-default automatic coin-selection setting, open a fixed quoted Savings request and verify the selected coins and fee follow that setting. Close, choose manual coin selection and reopen; verify the coin picker appears before payment can be approved. Go Back without selecting coins and verify Confirm stays open, allowing a funding change. Swipe again and verify coin selection is still required. Close without paying. Verify the requested amount cannot be edited and payment does not start without the confirmation swipe. Close the sheet and verify the unpaid request remains available to reopen. Reopen the same request, then close while Preparing is visible. Verify late completion does not reopen the sheet or start a payment. Reopen once more and verify confirmation is usable. diff --git a/journeys/pubky-auth/signin-with-second-link.xml b/journeys/pubky-auth/signin-with-second-link.xml index edfa81aa39..c66c94d88b 100644 --- a/journeys/pubky-auth/signin-with-second-link.xml +++ b/journeys/pubky-auth/signin-with-second-link.xml @@ -13,22 +13,22 @@ Verify the PIN pad is visible (testTag "PinPad") Run adb shell am start -a android.intent.action.VIEW -d "bitcoin:bcrt1qh6k3khs7qz04a9p5eam6v48nlug9d38x4rgxrn?amount=0.0001" to.bitkit.dev Enter the PIN with the number pad keys - Verify the authorization sheet is visible (testTag "PubkyAuthAuthorize") and the Send amount screen is not shown + Verify the authorization sheet is visible (testTag "PubkyAuthAuthorize") and the Send Confirm screen is not shown Press back to close the authorization sheet - Verify the Send amount screen opens (testTag "SendNumberField") showing 10 000 sats, then press back until the wallet home screen is visible + Verify the Send Confirm screen opens (title "Confirm", amount testTag "ReviewAmount") showing 10 000 sats, then press back until the wallet home screen is visible Run adb shell am force-stop to.bitkit.dev Run adb shell am start -a android.intent.action.VIEW -d "bitcoin:bcrt1qh6k3khs7qz04a9p5eam6v48nlug9d38x4rgxrn?amount=0.0001" to.bitkit.dev Verify the PIN pad is visible (testTag "PinPad") Run adb shell am start -a android.intent.action.VIEW -d "<a fresh signin_grant URL>" to.bitkit.dev Enter the PIN with the number pad keys - Verify the authorization sheet is visible (testTag "PubkyAuthAuthorize") and the Send amount screen is not shown + Verify the authorization sheet is visible (testTag "PubkyAuthAuthorize") and the Send Confirm screen is not shown Press back to close the authorization sheet - Verify the Send amount screen opens (testTag "SendNumberField") showing 10 000 sats, then press back until the wallet home screen is visible + Verify the Send Confirm screen opens (title "Confirm", amount testTag "ReviewAmount") showing 10 000 sats, then press back until the wallet home screen is visible Run adb shell am start -a android.intent.action.VIEW -d "<a fresh signin_grant URL>" to.bitkit.dev Verify the authorization sheet is visible (testTag "PubkyAuthAuthorize") Run adb shell am start -a android.intent.action.VIEW -d "bitcoin:bcrt1qh6k3khs7qz04a9p5eam6v48nlug9d38x4rgxrn?amount=0.0001" to.bitkit.dev - Verify the authorization sheet is still visible (testTag "PubkyAuthAuthorize") and the Send amount screen is not shown + Verify the authorization sheet is still visible (testTag "PubkyAuthAuthorize") and the Send Confirm screen is not shown Press back to close the authorization sheet - Verify the Send amount screen opens (testTag "SendNumberField") showing 10 000 sats + Verify the Send Confirm screen opens (title "Confirm", amount testTag "ReviewAmount") showing 10 000 sats diff --git a/journeys/security/pin-lock-on-resume.xml b/journeys/security/pin-lock-on-resume.xml index b124b62535..594d0d3f68 100644 --- a/journeys/security/pin-lock-on-resume.xml +++ b/journeys/security/pin-lock-on-resume.xml @@ -40,9 +40,9 @@ Tap Receive (testTag "Receive") and verify the Receive sheet is visible (testTag "ReceiveScreen") Run adb shell input keyevent KEYCODE_HOME and wait 3 seconds Run adb shell am start -a android.intent.action.VIEW -d "bitcoin:bcrt1qh6k3khs7qz04a9p5eam6v48nlug9d38x4rgxrn?amount=0.0001" to.bitkit.dev - Verify the PIN pad is visible and the Send amount screen is not shown + Verify the PIN pad is visible and the Send Confirm screen (testTag "ReviewAmount") is not shown Enter the PIN with the number pad keys - Verify the Send amount screen opens (testTag "SendNumberField") showing 10 000 sats and the Receive sheet (testTag "ReceiveScreen") is gone + Verify the Send Confirm screen opens (title "Confirm", amount testTag "ReviewAmount") showing 10 000 sats and the Receive sheet (testTag "ReceiveScreen") is gone Press back to close the Send sheet and verify the wallet home screen is visible diff --git a/journeys/send/onchain-accepted-result.xml b/journeys/send/onchain-accepted-result.xml index 9d18c74341..3aaff7b22e 100644 --- a/journeys/send/onchain-accepted-result.xml +++ b/journeys/send/onchain-accepted-result.xml @@ -10,8 +10,7 @@ Hand the receiving test wallet address to the app with adb shell am start -a android.intent.action.VIEW -d "bitcoin:<test recipient address>?amount=0.00001" to.bitkit.dev - Verify the Send Amount screen is visible with 1 000 sats and Savings selected - Tap Continue (tag "ContinueAmount"), expand Show Details (tag "SendConfirmToggleDetails") if collapsed, and verify the Confirm screen shows the receiving test wallet address (tag "ReviewUri") + Verify the Confirm screen opens directly with 1 000 sats (tag "ReviewAmount"), expand Show Details (tag "SendConfirmToggleDetails") if collapsed, and verify it shows Savings and the receiving test wallet address (tag "ReviewUri") Swipe the send handle (tag "GRAB") fully to the right Wait for the Bitcoin Sent success screen (tag "SendSuccess") and verify it shows the accepted payment amount Tap Details (tag "Details"), then Explore (tag "ActivityTxDetails"), and verify the outbound transaction shows a transaction ID (tag "TXID"); record that exact ID without inferring a different transaction from address or amount diff --git a/journeys/send/scan-onchain-amount-to-confirm.xml b/journeys/send/scan-onchain-amount-to-confirm.xml new file mode 100644 index 0000000000..5ca31e9775 --- /dev/null +++ b/journeys/send/scan-onchain-amount-to-confirm.xml @@ -0,0 +1,31 @@ + + + Verifies that an on-chain BIP21 request with an amount the Savings balance covers opens the + Confirm screen directly, with the amount, recipient and fee filled in, and that the amount is + fixed: tapping it does nothing, and Back from Confirm goes to the screen before it instead of + Amount. Nothing is broadcast. + Precondition: an onboarded dev wallet with between 200 000 and 1 000 000 confirmed sats in + Savings, funded through the lsp regtest deposit and mine (see ../README.md), Coin Selection set + to Autopilot (the default), no hardware wallet selected, and a regtest address the wallet does + not own. With manual coin selection the link also opens Confirm, and the swipe opens the coin + selection screen; see ../coin-selection/manual-coin-selection.xml. Start on the wallet home screen. + The Confirm screen is identified by its "Confirm" title and the amount (testTag "ReviewAmount"); + the Send Amount screen by its number field (testTag "SendNumberField"). + + + Run adb shell am start -a android.intent.action.VIEW -d "bitcoin:<regtest address>?amount=0.0001" to.bitkit.dev + Verify the Confirm screen opens without the Send Amount screen (testTag "SendNumberField") having been shown, and the amount (testTag "ReviewAmount") shows 10 000, waiting through "Connecting to network" if it is shown + Tap Show Details (testTag "SendConfirmToggleDetails") and verify the recipient (testTag "ReviewUri") is the regtest address and Speed and Fee shows a fee + Tap the amount (testTag "ReviewAmount") and verify the Confirm screen stays open with 10 000 and the Send Amount screen does not open + Verify no toolbar Back arrow (testTag "NavigationBack") is shown, since Confirm is the sheet's first screen + Run adb shell input keyevent KEYCODE_BACK and verify the Send sheet closes without sending and the Send Amount screen is not shown + Open Send (testTag "Send"), choose Enter Manually (testTag "RecipientManual"), enter "bitcoin:<regtest address>?amount=0.0001" into the recipient field (testTag "RecipientInput") and tap Continue (testTag "AddressContinue") + Verify the Confirm screen opens directly showing 10 000 + Tap the amount (testTag "ReviewAmount") and verify the Confirm screen stays open with 10 000 + Tap Back (testTag "NavigationBack") and verify the recipient field (testTag "RecipientInput") still holds the URI and the Send Amount screen is not shown; close the sheet without sending + Run adb shell am start -a android.intent.action.VIEW -d "bitcoin:<regtest address>?amount=0.01" to.bitkit.dev + Verify the Insufficient Savings toast appears (screenshot) and neither the Send Amount nor the Confirm screen opens + Run adb shell am start -a android.intent.action.VIEW -d "bitcoin:<regtest address>?amount=0.000005" to.bitkit.dev + Verify the Amount Too Low toast (testTag "AmountTooLowToast") says the minimum is 547 sats, and neither the Send Amount nor the Confirm screen opens + + diff --git a/journeys/send/scan-onchain-no-amount-to-amount.xml b/journeys/send/scan-onchain-no-amount-to-amount.xml new file mode 100644 index 0000000000..728807df87 --- /dev/null +++ b/journeys/send/scan-onchain-no-amount-to-amount.xml @@ -0,0 +1,18 @@ + + + Verifies that an on-chain BIP21 request without an amount, or with a zero amount, still opens + the Send Amount screen with an empty amount, so the user enters one before Confirm. + Precondition: an onboarded dev wallet with confirmed sats in Savings, funded through the lsp + regtest deposit and mine (see ../README.md), and a regtest address the wallet does not own. + Start on the wallet home screen. Nothing is broadcast. + + + Run adb shell am start -a android.intent.action.VIEW -d "bitcoin:<regtest address>" to.bitkit.dev + Verify the Send Amount screen (testTag "SendNumberField") is visible with an amount of 0 + Enter 10000 with the number pad keys (testTags "N1", "N0") and tap Continue (testTag "ContinueAmount") + Verify the Confirm screen opens with the amount (testTag "ReviewAmount") showing 10 000 + Tap Back (testTag "NavigationBack") and verify the Send Amount screen shows 10 000, then close the sheet without sending + Run adb shell am start -a android.intent.action.VIEW -d "bitcoin:<regtest address>?amount=0" to.bitkit.dev + Verify the Send Amount screen (testTag "SendNumberField") is visible with an amount of 0, then close the sheet without sending + + diff --git a/journeys/usdt-payment-links-and-scans.xml b/journeys/usdt-payment-links-and-scans.xml index a9751a1bde..a62aae6297 100644 --- a/journeys/usdt-payment-links-and-scans.xml +++ b/journeys/usdt-payment-links-and-scans.xml @@ -2,13 +2,13 @@ Use an onboarded wallet with USDT configured and another owned wallet's Arbitrum receive URI. No payment is submitted. Hardware-wallet send remains Bitcoin-only. A full-balance quote can fail at balance validation or paymaster fee collection; either error can occur on either platform, depending on which validation fails first. Both must reject before signing. Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow. - Open an ethereum: USDT receive link with chain 42161, the pinned Arbitrum token, an owned recipient and uint256=10001. Verify the USDT amount screen preserves the recipient and exact input 0.010001 USDT; the review rounds its displayed value to $0.01. Verify no payment occurs without approval. - Copy the USDT receive URI, return to Home and accept the clipboard prompt. Verify the USDT amount screen opens with the copied recipient and amount. Cancel without paying. Repeat with a wrong-chain URI and verify no payment prompt appears. + Open an ethereum: USDT receive link with chain 42161, the pinned Arbitrum token, an owned recipient and uint256=10001. Verify the link opens on payment review without tapping Review, preserving the recipient and exact input 0.010001 USDT; the review rounds its displayed value to $0.01. With a USDT balance below the amount plus fees, verify the review (testTag "UsdtConfirmLoading") shows the insufficient-balance or fee error with Try Again (testTag "UsdtRetryQuote") and the amount screen does not open. Verify the amount is fixed: tapping the amount on review does not open the amount screen, and Back from review returns to the recipient step instead of the amount screen. Continue on that step without changing the recipient and verify the review opens again with the same fixed amount, not an editable amount screen. Verify no payment occurs without approval. + Copy the USDT receive URI, return to Home and accept the clipboard prompt. Verify it opens on payment review without tapping Review, with the copied recipient and amount; with a USDT balance below the amount plus fees, verify the review shows the copied amount and the quote error, and the amount screen does not open. Cancel without paying. Repeat with a wrong-chain URI and verify no payment prompt appears. Cancel the send and remain on the USDT wallet without returning Home, then reopen the identical URI. Verify the amount or review screen opens again. Cancel back to the USDT wallet and open a different owned recipient and amount, verifying only the new values appear. Close the send sheet, open Settings from the drawer, then go back. Verify the dismissed link does not reopen its Amount screen. Explicitly reopen the same link and verify a new send flow is available, then cancel without paying. While a USDT contact amount screen, contact review or direct-send review is open, copy another owned bare USDT address and verify no automatic clipboard prompt appears. Repeat with a USDT payment URI. Open another USDT link, then scan another owned USDT address. Verify the active recipient, amount and contact or request are preserved, with no new send flow or invalid-QR error. Where PIN is enabled, repeat while its approval screen is visible, then cancel without paying. Open USDT from Home, open a USDT link, then open a second link with another owned recipient and amount. Verify the first amount and recipient remain. Cancel without paying. - Cancel the send. Open the same link without uint256. Verify the amount screen opens with no amount and accepts a new amount. + Cancel the send. Open the same link without uint256. Verify the amount screen opens with no amount and accepts a new amount, and that Back from review returns to the amount screen. Repeat with uint256=0 and verify the same. Cancel the send. From the main scanner, paste or import a QR containing the same amount-bearing URI. Verify it opens the USDT send flow with the same recipient and amount. Cancel the send. Scan another owned wallet's bare 0x USDT address from the main scanner, then repeat through ordinary Send paste and Enter Manually. Verify each opens USDT with the same recipient, an empty amount, Arbitrum One selected and the network chooser available. In hardware-wallet manual entry, verify the same address shows the Bitcoin-address-required warning and Continue stays disabled. Cancel without paying. Cancel the send. From the ordinary Send options, paste the URI. Verify it opens the USDT send flow. Cancel, choose Enter Manually, enter the same URI and continue. Verify the USDT recipient and exact amount are preserved. Repeat with a wrong-chain URI and in hardware-wallet Send; verify neither opens a USDT payment. In hardware-wallet manual entry, verify the Bitcoin-address-required warning appears and Continue stays disabled. Replace the URI with a valid Bitcoin address and verify Continue becomes available. Cancel, then open Send again from the USDT wallet and verify the previous recipient and amount are cleared.