From 5e952040ba39ac47587c8154823c73d123de31b2 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 3 Nov 2026 10:32:06 -0300 Subject: [PATCH 01/26] fix: open review for scanned requests with an amount Co-Authored-By: Claude Opus 5.5 --- .../screens/wallets/usdt/UsdtWalletScreen.kt | 37 ++++++ .../java/to/bitkit/ui/sheets/SendSheet.kt | 15 ++- .../java/to/bitkit/viewmodels/AppViewModel.kt | 27 ++++ .../wallets/usdt/UsdtWalletScreenTest.kt | 28 ++++ .../ui/sheets/SendSheetNavigationTest.kt | 27 ++++ .../viewmodels/AppViewModelSendFlowTest.kt | 125 ++++++++++++++++++ changelog.d/next/1763.fixed.md | 1 + journeys/boost/rbf-own-send.xml | 4 +- journeys/payment-requests/README.md | 4 +- .../payment-requests/fixed-price-bitcoin.xml | 10 +- .../fixed-quoted-confirmation.xml | 1 + .../pubky-auth/signin-with-second-link.xml | 12 +- journeys/security/pin-lock-on-resume.xml | 2 +- journeys/send/onchain-accepted-result.xml | 3 +- .../send/scan-onchain-amount-to-confirm.xml | 28 ++++ .../send/scan-onchain-no-amount-to-amount.xml | 18 +++ journeys/usdt-payment-links-and-scans.xml | 2 +- 17 files changed, 321 insertions(+), 23 deletions(-) create mode 100644 app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreenTest.kt create mode 100644 changelog.d/next/1763.fixed.md create mode 100644 journeys/send/scan-onchain-amount-to-confirm.xml create mode 100644 journeys/send/scan-onchain-no-amount-to-amount.xml 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..4127284871 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 @@ -135,6 +137,12 @@ fun UsdtWalletScreen( 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) { @@ -299,6 +307,35 @@ 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() && runCatching { PaykitAmount.parseInput(PaykitAsset.USDT, amount) }.isSuccess + @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/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 7906998ac3..b567c152d4 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -129,6 +129,13 @@ internal fun NavController.navigateToSendAmount(startDestination: SendRoute) { } } +/** Confirm opened straight from an invoice amount is replaced by Amount, so Back from Amount skips it. */ +internal fun NavController.navigateToAmountFromConfirm() { + navigateTo(SendRoute.Amount) { + popUpTo { inclusive = true } + } +} + internal fun NavController.navigateToSendPending(route: SendRoute.Pending) { navigateTo(route) { popUpTo(graph.id) { inclusive = true } } } @@ -498,11 +505,13 @@ fun SendSheet( lightningState.nodeLifecycleState.isRunning(), canAutoStart = !isOffline && !shouldShowSyncOverlay, syncNodeVariant = syncNodeVariant, - canGoBack = navController.previousBackStackEntry != null, + canGoBack = navController.previousBackStackEntry != null || uiState.canReturnToAmount, onBack = { val didPopToAmount = navController.popBackStack(SendRoute.Amount, inclusive = false) - if (!didPopToAmount && !navController.popBackStack()) { - appViewModel.hideSheet() + when { + didPopToAmount -> Unit + uiState.canReturnToAmount -> navController.navigateToAmountFromConfirm() + !navController.popBackStack() -> appViewModel.hideSheet() } }, onEvent = { e -> appViewModel.setSendEvent(e) }, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 95bcf05879..e7c31ef29b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -5399,6 +5399,8 @@ class AppViewModel @Inject constructor( return } + if (amount > 0u && openOnchainConfirmForInvoiceAmount(amount, fromMainScanner, walletGeneration)) return + Logger.info( when (amount > 0u) { true -> "Found amount in invoice, proceeding to edit amount" @@ -5410,6 +5412,30 @@ class AppViewModel @Inject constructor( navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount, walletGeneration) } + /** + * An on-chain invoice with a payable amount opens on Confirm, as Amount's continue would. + * Manual coin selection and contact payments keep going through Amount. + */ + private suspend fun openOnchainConfirmForInvoiceAmount( + amount: ULong, + fromMainScanner: Boolean, + walletGeneration: Long?, + ): Boolean { + if (_sendUiState.value.payMethod != SendMethod.ONCHAIN || hasActiveContactPaymentContext()) return false + if (!validateAmount(amount)) return false + val isHardware = _sendUiState.value.hardwareWalletId != null + if (!isHardware && !settingsStore.data.first().coinSelectAuto) return false + requireCurrentPaymentPreparation(walletGeneration) + if (isHardware && !prepareHardwareSendFee()) return false + requireCurrentPaymentPreparation(walletGeneration) + + Logger.info("Found payable amount in invoice, proceeding to confirm", context = TAG) + _sendUiState.update { it.copy(isAmountInputValid = true, canReturnToAmount = true) } + navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm, walletGeneration) + refreshOnchainSendIfNeeded(walletGeneration) + return true + } + private suspend fun hardwareMaxSpendable( walletId: String, address: String, @@ -8935,6 +8961,7 @@ data class SendUiState( val isInitialSubscriptionPayment: Boolean = false, val initialSubscriptionPaymentAutoStartPending: Boolean = false, val requiresAmountReview: Boolean = false, + val canReturnToAmount: Boolean = false, val initialSubscriptionPaymentNeedsConfirmation: Boolean = false, val incomingPaymentRequestId: PaykitPaymentRequestId? = 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..04bdba14bd --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreenTest.kt @@ -0,0 +1,28 @@ +package to.bitkit.ui.screens.wallets.usdt + +import org.junit.Test +import kotlin.test.assertFalse +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")) + } +} 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 eb65547d98..4275e2485f 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt @@ -47,6 +47,33 @@ class SendSheetNavigationTest { } } + @Test + fun `back from a confirm opened by an invoice amount replaces it with amount`() { + for (start in listOf(SendRoute.Confirm, SendRoute.Recipient)) { + val navController = NavHostController(ApplicationProvider.getApplicationContext()).apply { + navigatorProvider.addNavigator(ComposeNavigator()) + graph = createGraph(startDestination = start) { + composable {} + composable {} + composable {} + } + } + if (start != SendRoute.Confirm) navController.navigateTo(SendRoute.Confirm) + + navController.navigateToAmountFromConfirm() + + assertTrue(requireNotNull(navController.currentDestination).hasRoute()) + val previous = navController.previousBackStackEntry?.destination + if (start == SendRoute.Confirm) { + assertNull(previous) + } else { + assertTrue(requireNotNull(previous).hasRoute()) + } + navController.navigateTo(SendRoute.Confirm) + assertTrue(navController.popBackStack(SendRoute.Amount, inclusive = false)) + } + } + @Test fun `pending clears earlier send screens after prepared amount review`() { for (start in listOf(SendRoute.Confirm, SendRoute.ContactSelect, SendRoute.Amount)) { diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 89de855cf8..a3ed611382 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 @@ -9329,6 +9330,89 @@ 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) + assertTrue(state.canReturnToAmount) + } + + @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) + assertFalse(sut.sendUiState.value.canReturnToAmount) + } + + @Test + fun `main scanner onchain scan with dust amount opens amount sheet`() = test { + val input = stubOnchainAmountScan(amountSats = Defaults.dustLimit.toULong()) + + sut.showScannerSheet() + sut.onScannerSheetResult(input) + advanceUntilIdle() + + assertEquals(Sheet.Send(SendRoute.Amount), sut.currentSheet.value) + assertFalse(sut.sendUiState.value.canReturnToAmount) + } + + @Test + fun `main scanner onchain scan with amount opens amount 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.Amount), sut.currentSheet.value) + assertEquals(10_000uL, sut.sendUiState.value.amount) + assertFalse(sut.sendUiState.value.canReturnToAmount) + } + + @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() + } + assertTrue(sut.sendUiState.value.canReturnToAmount) + } + + 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 +16816,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/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..e66771c807 100644 --- a/journeys/security/pin-lock-on-resume.xml +++ b/journeys/security/pin-lock-on-resume.xml @@ -42,7 +42,7 @@ 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 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..1b5a43d08e --- /dev/null +++ b/journeys/send/scan-onchain-amount-to-confirm.xml @@ -0,0 +1,28 @@ + + + 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 Back from Confirm + opens Amount with the same amount so it can still be edited. 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. Manual coin selection keeps going through Amount and then 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 Back (testTag "NavigationBack") + Verify the Send Amount screen (testTag "SendNumberField") is visible with 10 000 sats + Tap Continue (testTag "ContinueAmount") and verify the Confirm screen shows 10 000 again + Tap Back (testTag "NavigationBack") and verify the Send Amount screen is visible, then tap Back (testTag "NavigationBack") and verify the wallet home screen is visible without sending + 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 Back (testTag "NavigationBack") and verify the Send Amount screen shows 10 000, then tap Back (testTag "NavigationBack") and verify the recipient field still holds the URI; 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 + + 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..4308667b7d 100644 --- a/journeys/usdt-payment-links-and-scans.xml +++ b/journeys/usdt-payment-links-and-scans.xml @@ -2,7 +2,7 @@ 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. + 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 amount screen stays open with the same insufficient-balance or fee error that tapping Review shows. Go back to the recipient and continue again; verify the review is not requested again until Review is tapped. 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. 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. From 1d280b6c59a51ede602149b453981acf160656b7 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 3 Nov 2026 10:50:19 -0300 Subject: [PATCH 02/26] fix: route system back on direct confirm to amount Co-Authored-By: Claude Opus 5.5 --- .../java/to/bitkit/ui/sheets/SendSheet.kt | 25 +++++++++++++------ .../ui/sheets/SendSheetNavigationTest.kt | 19 ++++++++++++++ .../send/scan-onchain-amount-to-confirm.xml | 3 +++ 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index b567c152d4..54e76cb77e 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -136,6 +136,16 @@ internal fun NavController.navigateToAmountFromConfirm() { } } +/** Back from Confirm, shared by the toolbar arrow and system Back. Returns false when the sheet should close. */ +internal fun NavController.backFromConfirm(canReturnToAmount: Boolean): Boolean { + if (popBackStack(SendRoute.Amount, inclusive = false)) return true + if (canReturnToAmount) { + navigateToAmountFromConfirm() + return true + } + return popBackStack() +} + internal fun NavController.navigateToSendPending(route: SendRoute.Pending) { navigateTo(route) { popUpTo(graph.id) { inclusive = true } } } @@ -498,6 +508,12 @@ fun SendSheet( initialSubscriptionId?.let(appViewModel::subscription) } + val onConfirmBack = { + if (!navController.backFromConfirm(uiState.canReturnToAmount)) appViewModel.hideSheet() + } + // Confirm can be the sheet's first screen, where the nav host does not handle system Back. + BackHandler(enabled = uiState.canReturnToAmount, onBack = onConfirmBack) + SendConfirmScreen( savedStateHandle = it.savedStateHandle, uiState = uiState, @@ -506,14 +522,7 @@ fun SendSheet( canAutoStart = !isOffline && !shouldShowSyncOverlay, syncNodeVariant = syncNodeVariant, canGoBack = navController.previousBackStackEntry != null || uiState.canReturnToAmount, - onBack = { - val didPopToAmount = navController.popBackStack(SendRoute.Amount, inclusive = false) - when { - didPopToAmount -> Unit - uiState.canReturnToAmount -> navController.navigateToAmountFromConfirm() - !navController.popBackStack() -> appViewModel.hideSheet() - } - }, + onBack = onConfirmBack, onEvent = { e -> appViewModel.setSendEvent(e) }, onClickAddTag = { navController.navigateTo(SendRoute.AddTag) }, onClickTag = { tag -> appViewModel.removeTag(tag) }, 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 4275e2485f..80c4efd71e 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt @@ -12,6 +12,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import to.bitkit.ui.navigateTo +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -74,6 +75,24 @@ class SendSheetNavigationTest { } } + @Test + fun `back from a first confirm opens amount only when it can return to amount`() { + for (canReturnToAmount in listOf(true, false)) { + val navController = NavHostController(ApplicationProvider.getApplicationContext()).apply { + navigatorProvider.addNavigator(ComposeNavigator()) + graph = createGraph(startDestination = SendRoute.Confirm) { + composable {} + composable {} + } + } + + val handled = navController.backFromConfirm(canReturnToAmount) + + assertEquals(canReturnToAmount, handled) + assertEquals(canReturnToAmount, navController.currentDestination?.hasRoute() == true) + } + } + @Test fun `pending clears earlier send screens after prepared amount review`() { for (start in listOf(SendRoute.Confirm, SendRoute.ContactSelect, SendRoute.Amount)) { diff --git a/journeys/send/scan-onchain-amount-to-confirm.xml b/journeys/send/scan-onchain-amount-to-confirm.xml index 1b5a43d08e..441a36a139 100644 --- a/journeys/send/scan-onchain-amount-to-confirm.xml +++ b/journeys/send/scan-onchain-amount-to-confirm.xml @@ -19,6 +19,9 @@ Verify the Send Amount screen (testTag "SendNumberField") is visible with 10 000 sats Tap Continue (testTag "ContinueAmount") and verify the Confirm screen shows 10 000 again Tap Back (testTag "NavigationBack") and verify the Send Amount screen is visible, then tap Back (testTag "NavigationBack") and verify the wallet home screen is visible without sending + Run adb shell am start -a android.intent.action.VIEW -d "bitcoin:<regtest address>?amount=0.0001" to.bitkit.dev and verify the Confirm screen shows 10 000 + Run adb shell input keyevent KEYCODE_BACK and verify the Send Amount screen (testTag "SendNumberField") is visible with 10 000 sats rather than the sheet closing + Run adb shell input keyevent KEYCODE_BACK and verify the Send sheet closes without sending 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 Back (testTag "NavigationBack") and verify the Send Amount screen shows 10 000, then tap Back (testTag "NavigationBack") and verify the recipient field still holds the URI; close the sheet without sending From 5a8cda718d47629a727982e0d59de441fab8e2e8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 06:02:18 -0300 Subject: [PATCH 03/26] fix: open amount when tapping it on a direct confirm Co-Authored-By: Claude Opus 5.5 --- .../main/java/to/bitkit/ui/sheets/SendSheet.kt | 18 +++++++++++------- .../ui/sheets/SendSheetNavigationTest.kt | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 54e76cb77e..9b6ee9e18f 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -136,16 +136,18 @@ internal fun NavController.navigateToAmountFromConfirm() { } } -/** Back from Confirm, shared by the toolbar arrow and system Back. Returns false when the sheet should close. */ -internal fun NavController.backFromConfirm(canReturnToAmount: Boolean): Boolean { +/** Returns to Amount, opening it in place of a Confirm reached without it. Returns false when there is none. */ +internal fun NavController.returnToAmount(canReturnToAmount: Boolean): Boolean { if (popBackStack(SendRoute.Amount, inclusive = false)) return true - if (canReturnToAmount) { - navigateToAmountFromConfirm() - return true - } - return popBackStack() + if (!canReturnToAmount) return false + navigateToAmountFromConfirm() + return true } +/** Back from Confirm, shared by the toolbar arrow and system Back. Returns false when the sheet should close. */ +internal fun NavController.backFromConfirm(canReturnToAmount: Boolean): Boolean = + returnToAmount(canReturnToAmount) || popBackStack() + internal fun NavController.navigateToSendPending(route: SendRoute.Pending) { navigateTo(route) { popUpTo(graph.id) { inclusive = true } } } @@ -316,6 +318,8 @@ fun SendSheet( navController.navigateTo(it.route) { popUpTo(replaced) { inclusive = true } } } is SendEffect.NavigateToHardwareSign -> navController.navigateTo(SendRoute.HardwareSign) + is SendEffect.PopBack if it.route == SendRoute.Amount -> + navController.returnToAmount(appViewModel.sendUiState.value.canReturnToAmount) is SendEffect.PopBack -> navController.popBackStack(it.route, inclusive = false) is SendEffect.PaymentSuccess -> { val details = appViewModel.successSendUiState.value 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 80c4efd71e..b90c21612f 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt @@ -93,6 +93,22 @@ class SendSheetNavigationTest { } } + @Test + fun `return to amount opens amount from a first confirm only when it can return to amount`() { + for (canReturnToAmount in listOf(true, false)) { + val navController = NavHostController(ApplicationProvider.getApplicationContext()).apply { + navigatorProvider.addNavigator(ComposeNavigator()) + graph = createGraph(startDestination = SendRoute.Confirm) { + composable {} + composable {} + } + } + + assertEquals(canReturnToAmount, navController.returnToAmount(canReturnToAmount)) + assertEquals(canReturnToAmount, navController.currentDestination?.hasRoute() == true) + } + } + @Test fun `pending clears earlier send screens after prepared amount review`() { for (start in listOf(SendRoute.Confirm, SendRoute.ContactSelect, SendRoute.Amount)) { From 2017ebd9425d3d5c33fef7ef1e22e49e70fe5191 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 06:02:18 -0300 Subject: [PATCH 04/26] test: cover hardware fee estimate on direct confirm Co-Authored-By: Claude Opus 5.5 --- .../viewmodels/AppViewModelSendFlowTest.kt | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index a3ed611382..b63195365d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9400,6 +9400,50 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertTrue(sut.sendUiState.value.canReturnToAmount) } + @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) + assertTrue(sut.sendUiState.value.canReturnToAmount) + } + + @Test + fun `hardware send sheet onchain address opens amount 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() + + assertEquals(SendEffect.NavigateToAmount, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + assertFalse(sut.sendUiState.value.canReturnToAmount) + } + + 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), From fa34ec9820d4ea40ff6a077c13eaa6a72ce2890f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 06:02:18 -0300 Subject: [PATCH 05/26] chore: drop kdoc from private confirm helper Co-Authored-By: Claude Opus 5.5 --- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index e7c31ef29b..b597f8c26f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -5412,10 +5412,6 @@ class AppViewModel @Inject constructor( navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount, walletGeneration) } - /** - * An on-chain invoice with a payable amount opens on Confirm, as Amount's continue would. - * Manual coin selection and contact payments keep going through Amount. - */ private suspend fun openOnchainConfirmForInvoiceAmount( amount: ULong, fromMainScanner: Boolean, From c15f207e7a9d51af049ea8a0e888b3d7f5909bc9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 06:02:18 -0300 Subject: [PATCH 06/26] docs: update pin and usdt journeys for direct review Co-Authored-By: Claude Opus 5.5 --- journeys/security/pin-lock-on-resume.xml | 1 + journeys/usdt-payment-links-and-scans.xml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/journeys/security/pin-lock-on-resume.xml b/journeys/security/pin-lock-on-resume.xml index e66771c807..e2912932d4 100644 --- a/journeys/security/pin-lock-on-resume.xml +++ b/journeys/security/pin-lock-on-resume.xml @@ -43,6 +43,7 @@ Verify the PIN pad is visible and the Send amount screen is not shown Enter the PIN with the number pad keys 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 and verify the Send Amount screen (testTag "SendNumberField") shows 10 000 sats Press back to close the Send sheet and verify the wallet home screen is visible diff --git a/journeys/usdt-payment-links-and-scans.xml b/journeys/usdt-payment-links-and-scans.xml index 4308667b7d..7a4b5d85df 100644 --- a/journeys/usdt-payment-links-and-scans.xml +++ b/journeys/usdt-payment-links-and-scans.xml @@ -3,7 +3,7 @@ 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 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 amount screen stays open with the same insufficient-balance or fee error that tapping Review shows. Go back to the recipient and continue again; verify the review is not requested again until Review is tapped. 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. + 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 amount screen stays open with the copied recipient and amount and the quote error. 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. From bdb159307a7a0e408ff4ce61205a3b5151167200 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 09:24:36 -0300 Subject: [PATCH 07/26] docs: drive the amount tap in the scan to confirm journey Co-Authored-By: Claude Opus 5.5 --- journeys/send/scan-onchain-amount-to-confirm.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/journeys/send/scan-onchain-amount-to-confirm.xml b/journeys/send/scan-onchain-amount-to-confirm.xml index 441a36a139..30145b4c3a 100644 --- a/journeys/send/scan-onchain-amount-to-confirm.xml +++ b/journeys/send/scan-onchain-amount-to-confirm.xml @@ -2,7 +2,7 @@ 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 Back from Confirm - opens Amount with the same amount so it can still be edited. Nothing is broadcast. + opens Amount with the same amount so it can still be edited, and that tapping the amount on Confirm does the same. 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 @@ -15,6 +15,8 @@ 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 Send Amount screen (testTag "SendNumberField") opens with 10 000 sats + Tap Continue (testTag "ContinueAmount") and verify the Confirm screen shows 10 000 again Tap Back (testTag "NavigationBack") Verify the Send Amount screen (testTag "SendNumberField") is visible with 10 000 sats Tap Continue (testTag "ContinueAmount") and verify the Confirm screen shows 10 000 again From 0a1c83a0189d4b7b8f208f004a7c91ca41174557 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 09:24:36 -0300 Subject: [PATCH 08/26] docs: check confirm stays hidden behind the pin pad Co-Authored-By: Claude Opus 5.5 --- journeys/security/pin-lock-on-resume.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/journeys/security/pin-lock-on-resume.xml b/journeys/security/pin-lock-on-resume.xml index e2912932d4..75cdc810bc 100644 --- a/journeys/security/pin-lock-on-resume.xml +++ b/journeys/security/pin-lock-on-resume.xml @@ -40,7 +40,7 @@ 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 Confirm screen opens (title "Confirm", amount testTag "ReviewAmount") showing 10 000 sats and the Receive sheet (testTag "ReceiveScreen") is gone Press back and verify the Send Amount screen (testTag "SendNumberField") shows 10 000 sats From cfee6e42552e64be4a2468aaaf06e1d1bb499a3b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 10:01:13 -0300 Subject: [PATCH 09/26] docs: keep toolbar back first on the link-opened confirm Co-Authored-By: Claude Opus 5.5 --- journeys/send/scan-onchain-amount-to-confirm.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/journeys/send/scan-onchain-amount-to-confirm.xml b/journeys/send/scan-onchain-amount-to-confirm.xml index 30145b4c3a..82b20f8a5a 100644 --- a/journeys/send/scan-onchain-amount-to-confirm.xml +++ b/journeys/send/scan-onchain-amount-to-confirm.xml @@ -15,8 +15,6 @@ 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 Send Amount screen (testTag "SendNumberField") opens with 10 000 sats - Tap Continue (testTag "ContinueAmount") and verify the Confirm screen shows 10 000 again Tap Back (testTag "NavigationBack") Verify the Send Amount screen (testTag "SendNumberField") is visible with 10 000 sats Tap Continue (testTag "ContinueAmount") and verify the Confirm screen shows 10 000 again @@ -26,6 +24,8 @@ Run adb shell input keyevent KEYCODE_BACK and verify the Send sheet closes without sending 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") on this Confirm, which has no Amount screen beneath it, and verify the Send Amount screen (testTag "SendNumberField") opens with 10 000 sats + Tap Continue (testTag "ContinueAmount") and verify the Confirm screen shows 10 000 again Tap Back (testTag "NavigationBack") and verify the Send Amount screen shows 10 000, then tap Back (testTag "NavigationBack") and verify the recipient field still holds the URI; 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 From 4cc1eca71e373029344efd806adefff442b2ed98 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 16:22:34 -0300 Subject: [PATCH 10/26] fix: keep back on a direct confirm from opening amount Co-Authored-By: Claude Opus 5.5 --- .../java/to/bitkit/ui/sheets/SendSheet.kt | 36 +++-------- .../ui/sheets/SendSheetNavigationTest.kt | 62 ------------------- 2 files changed, 7 insertions(+), 91 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 9b6ee9e18f..7906998ac3 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -129,25 +129,6 @@ internal fun NavController.navigateToSendAmount(startDestination: SendRoute) { } } -/** Confirm opened straight from an invoice amount is replaced by Amount, so Back from Amount skips it. */ -internal fun NavController.navigateToAmountFromConfirm() { - navigateTo(SendRoute.Amount) { - popUpTo { inclusive = true } - } -} - -/** Returns to Amount, opening it in place of a Confirm reached without it. Returns false when there is none. */ -internal fun NavController.returnToAmount(canReturnToAmount: Boolean): Boolean { - if (popBackStack(SendRoute.Amount, inclusive = false)) return true - if (!canReturnToAmount) return false - navigateToAmountFromConfirm() - return true -} - -/** Back from Confirm, shared by the toolbar arrow and system Back. Returns false when the sheet should close. */ -internal fun NavController.backFromConfirm(canReturnToAmount: Boolean): Boolean = - returnToAmount(canReturnToAmount) || popBackStack() - internal fun NavController.navigateToSendPending(route: SendRoute.Pending) { navigateTo(route) { popUpTo(graph.id) { inclusive = true } } } @@ -318,8 +299,6 @@ fun SendSheet( navController.navigateTo(it.route) { popUpTo(replaced) { inclusive = true } } } is SendEffect.NavigateToHardwareSign -> navController.navigateTo(SendRoute.HardwareSign) - is SendEffect.PopBack if it.route == SendRoute.Amount -> - navController.returnToAmount(appViewModel.sendUiState.value.canReturnToAmount) is SendEffect.PopBack -> navController.popBackStack(it.route, inclusive = false) is SendEffect.PaymentSuccess -> { val details = appViewModel.successSendUiState.value @@ -512,12 +491,6 @@ fun SendSheet( initialSubscriptionId?.let(appViewModel::subscription) } - val onConfirmBack = { - if (!navController.backFromConfirm(uiState.canReturnToAmount)) appViewModel.hideSheet() - } - // Confirm can be the sheet's first screen, where the nav host does not handle system Back. - BackHandler(enabled = uiState.canReturnToAmount, onBack = onConfirmBack) - SendConfirmScreen( savedStateHandle = it.savedStateHandle, uiState = uiState, @@ -525,8 +498,13 @@ fun SendSheet( lightningState.nodeLifecycleState.isRunning(), canAutoStart = !isOffline && !shouldShowSyncOverlay, syncNodeVariant = syncNodeVariant, - canGoBack = navController.previousBackStackEntry != null || uiState.canReturnToAmount, - onBack = onConfirmBack, + canGoBack = navController.previousBackStackEntry != null, + onBack = { + val didPopToAmount = navController.popBackStack(SendRoute.Amount, inclusive = false) + if (!didPopToAmount && !navController.popBackStack()) { + appViewModel.hideSheet() + } + }, onEvent = { e -> appViewModel.setSendEvent(e) }, onClickAddTag = { navController.navigateTo(SendRoute.AddTag) }, onClickTag = { tag -> appViewModel.removeTag(tag) }, 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 b90c21612f..eb65547d98 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt @@ -12,7 +12,6 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import to.bitkit.ui.navigateTo -import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -48,67 +47,6 @@ class SendSheetNavigationTest { } } - @Test - fun `back from a confirm opened by an invoice amount replaces it with amount`() { - for (start in listOf(SendRoute.Confirm, SendRoute.Recipient)) { - val navController = NavHostController(ApplicationProvider.getApplicationContext()).apply { - navigatorProvider.addNavigator(ComposeNavigator()) - graph = createGraph(startDestination = start) { - composable {} - composable {} - composable {} - } - } - if (start != SendRoute.Confirm) navController.navigateTo(SendRoute.Confirm) - - navController.navigateToAmountFromConfirm() - - assertTrue(requireNotNull(navController.currentDestination).hasRoute()) - val previous = navController.previousBackStackEntry?.destination - if (start == SendRoute.Confirm) { - assertNull(previous) - } else { - assertTrue(requireNotNull(previous).hasRoute()) - } - navController.navigateTo(SendRoute.Confirm) - assertTrue(navController.popBackStack(SendRoute.Amount, inclusive = false)) - } - } - - @Test - fun `back from a first confirm opens amount only when it can return to amount`() { - for (canReturnToAmount in listOf(true, false)) { - val navController = NavHostController(ApplicationProvider.getApplicationContext()).apply { - navigatorProvider.addNavigator(ComposeNavigator()) - graph = createGraph(startDestination = SendRoute.Confirm) { - composable {} - composable {} - } - } - - val handled = navController.backFromConfirm(canReturnToAmount) - - assertEquals(canReturnToAmount, handled) - assertEquals(canReturnToAmount, navController.currentDestination?.hasRoute() == true) - } - } - - @Test - fun `return to amount opens amount from a first confirm only when it can return to amount`() { - for (canReturnToAmount in listOf(true, false)) { - val navController = NavHostController(ApplicationProvider.getApplicationContext()).apply { - navigatorProvider.addNavigator(ComposeNavigator()) - graph = createGraph(startDestination = SendRoute.Confirm) { - composable {} - composable {} - } - } - - assertEquals(canReturnToAmount, navController.returnToAmount(canReturnToAmount)) - assertEquals(canReturnToAmount, navController.currentDestination?.hasRoute() == true) - } - } - @Test fun `pending clears earlier send screens after prepared amount review`() { for (start in listOf(SendRoute.Confirm, SendRoute.ContactSelect, SendRoute.Amount)) { From 4edce347c0cf006ac8a890b8e54673973e801727 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 16:22:34 -0300 Subject: [PATCH 11/26] fix: lock the amount of a scanned onchain request Co-Authored-By: Claude Opus 5.5 --- .../wallets/send/SendConfirmNavigationTest.kt | 51 +++++++++++++++++++ .../java/to/bitkit/viewmodels/AppViewModel.kt | 11 ++-- .../viewmodels/AppViewModelSendFlowTest.kt | 19 ++++--- 3 files changed, 70 insertions(+), 11 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmNavigationTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmNavigationTest.kt index d5ebb95538..2b5796906c 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmNavigationTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmNavigationTest.kt @@ -169,6 +169,57 @@ class SendConfirmNavigationTest { } } + @Test + fun tappingAFixedScannedAmountDoesNotOpenAmount() { + val state = mutableStateOf(SendUiState(amount = 10_000u, isAmountInputValid = true, fixedOnchainAmount = 10_000u)) + val events = mutableListOf() + composeTestRule.setContent { + val context = LocalContext.current + val settings = viewModel { + SettingsViewModel( + context, + settingsStore, + pubkyRepo, + contactPaymentSettingsRepo, + widgetsStore, + widgetsRepo, + usdtPreferences + ) + } + AppThemeSurface { + CompositionLocalProvider( + LocalSettingsViewModel provides settings, + LocalInspectionMode provides true, + ) { + val navController = rememberNavController() + NavHost(navController = navController, startDestination = SendRoute.Confirm) { + composable { entry -> + SendConfirmScreen( + savedStateHandle = entry.savedStateHandle, + uiState = state.value, + isNodeRunning = true, + canAutoStart = false, + canGoBack = false, + onBack = {}, + onEvent = { events += it }, + onClickAddTag = {}, + onClickTag = {}, + onNavigateToPin = {}, + ) + } + } + } + } + } + + composeTestRule.onNodeWithTag("ReviewAmount").performClick() + composeTestRule.runOnIdle { assertEquals(false, SendEvent.BackToAmount in events) } + + composeTestRule.runOnIdle { state.value = state.value.copy(fixedOnchainAmount = null) } + composeTestRule.onNodeWithTag("ReviewAmount").performClick() + composeTestRule.runOnIdle { assertEquals(true, SendEvent.BackToAmount in events) } + } + private fun swipeToConfirm() { val width = composeTestRule.onRoot().fetchSemanticsNode().size.width composeTestRule.onNodeWithTag("GRAB").performTouchInput { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index b597f8c26f..77d3b57187 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, @@ -5266,6 +5266,7 @@ class AppViewModel @Inject constructor( }, hardwareWalletId = hardwareWalletId, hardwareAvailableSats = selectedMaxSendOnchain.takeIf { hardwareWalletId != null } ?: 0uL, + fixedOnchainAmount = invoice.amountSatoshis.takeIf { it > 0uL && incomingPaymentRequest == null }, ) } updateCanSwitchWallet() @@ -5426,7 +5427,7 @@ class AppViewModel @Inject constructor( requireCurrentPaymentPreparation(walletGeneration) Logger.info("Found payable amount in invoice, proceeding to confirm", context = TAG) - _sendUiState.update { it.copy(isAmountInputValid = true, canReturnToAmount = true) } + _sendUiState.update { it.copy(isAmountInputValid = true) } navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm, walletGeneration) refreshOnchainSendIfNeeded(walletGeneration) return true @@ -8957,7 +8958,8 @@ data class SendUiState( val isInitialSubscriptionPayment: Boolean = false, val initialSubscriptionPaymentAutoStartPending: Boolean = false, val requiresAmountReview: Boolean = false, - val canReturnToAmount: 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, ) { @@ -8981,7 +8983,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/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index b63195365d..e0f7706c24 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9343,7 +9343,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(10_000uL, state.amount) assertEquals(SendMethod.ONCHAIN, state.payMethod) assertTrue(state.isAmountInputValid) - assertTrue(state.canReturnToAmount) + assertFalse(state.isAmountEditable) } @Test @@ -9355,7 +9355,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() assertEquals(Sheet.Send(SendRoute.Amount), sut.currentSheet.value) - assertFalse(sut.sendUiState.value.canReturnToAmount) + assertTrue(sut.sendUiState.value.isAmountEditable) } @Test @@ -9367,7 +9367,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { advanceUntilIdle() assertEquals(Sheet.Send(SendRoute.Amount), sut.currentSheet.value) - assertFalse(sut.sendUiState.value.canReturnToAmount) + assertFalse(sut.sendUiState.value.isAmountEditable) } @Test @@ -9381,7 +9381,12 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(Sheet.Send(SendRoute.Amount), sut.currentSheet.value) assertEquals(10_000uL, sut.sendUiState.value.amount) - assertFalse(sut.sendUiState.value.canReturnToAmount) + assertFalse(sut.sendUiState.value.isAmountEditable) + + sut.setSendEvent(SendEvent.AmountChange(5_000u)) + advanceUntilIdle() + + assertEquals(10_000uL, sut.sendUiState.value.amount) } @Test @@ -9397,7 +9402,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(SendEffect.NavigateToConfirm, awaitItem()) cancelAndIgnoreRemainingEvents() } - assertTrue(sut.sendUiState.value.canReturnToAmount) + assertFalse(sut.sendUiState.value.isAmountEditable) } @Test @@ -9417,7 +9422,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } verify(hwWalletRepo).estimateFundingMiningFee(eq(HARDWARE_WALLET_ID), eq(REGTEST_ADDRESS), eq(10_000uL), any()) assertEquals(HARDWARE_WALLET_ID, sut.sendUiState.value.hardwareWalletId) - assertTrue(sut.sendUiState.value.canReturnToAmount) + assertFalse(sut.sendUiState.value.isAmountEditable) } @Test @@ -9436,7 +9441,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { assertEquals(SendEffect.NavigateToAmount, awaitItem()) cancelAndIgnoreRemainingEvents() } - assertFalse(sut.sendUiState.value.canReturnToAmount) + assertFalse(sut.sendUiState.value.isAmountEditable) } private fun stubHardwareFunding() { From f2cc6f9c2084a42c890dcf027b827166d36f8f3e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 16:22:34 -0300 Subject: [PATCH 12/26] fix: lock the amount of a scanned usdt request Co-Authored-By: Claude Opus 5.5 --- .../to/bitkit/ui/screens/wallets/usdt/UsdtWalletScreen.kt | 8 ++++++++ 1 file changed, 8 insertions(+) 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 4127284871..061a45da3a 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 @@ -134,6 +134,8 @@ fun UsdtWalletScreen( var selectedTransferId by rememberSaveable(initialTransferId) { mutableStateOf(initialTransferId) } var recipient by rememberSaveable { mutableStateOf(initialRecipient) } var amount by rememberSaveable { mutableStateOf(initialAmount) } + // A scanned or linked request that names an amount fixes it; the payee set it. + var isAmountFixed by rememberSaveable { mutableStateOf(initialAmount.isNotEmpty()) } var maximumAmount by rememberSaveable { mutableStateOf(null) } val destinations by viewModel.destinations.collectAsStateWithLifecycle() var destination by rememberSaveable { mutableStateOf(UsdtDestination.ARBITRUM) } @@ -155,6 +157,10 @@ fun UsdtWalletScreen( state.busy -> Unit state.authenticationRequired -> viewModel.cancelAuthentication() state.submitted -> dismiss() + state.quote != null && isAmountFixed -> { + viewModel.edit() + page = UsdtPage.MANUAL + } state.quote != null -> viewModel.edit() page in listOf(UsdtPage.AMOUNT, UsdtPage.MANUAL) -> { viewModel.edit() @@ -277,6 +283,7 @@ fun UsdtWalletScreen( if (request.chainId != null) destination = UsdtDestination.ARBITRUM recipient = request.recipient amount = request.amount?.let { usdtFormatAmount(it) } ?: amount + isAmountFixed = request.amount != null page = UsdtPage.AMOUNT } }, @@ -296,6 +303,7 @@ fun UsdtWalletScreen( onConfirm = { viewModel.confirm() }, onRetryRecovery = { viewModel.retryRecovery() }, onDone = dismiss, hideBalance = settings.hideBalance, + amountEditable = !isAmountFixed, onDetails = { selectedTransferId = state.quote?.id dismiss() From 7376f159f66eaebea3bd6ce7c087325b72134a2f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 16:22:34 -0300 Subject: [PATCH 13/26] docs: update journeys for fixed scanned amounts Co-Authored-By: Claude Opus 5.5 --- journeys/security/pin-lock-on-resume.xml | 1 - .../send/scan-onchain-amount-to-confirm.xml | 20 ++++++++----------- journeys/usdt-payment-links-and-scans.xml | 4 ++-- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/journeys/security/pin-lock-on-resume.xml b/journeys/security/pin-lock-on-resume.xml index 75cdc810bc..594d0d3f68 100644 --- a/journeys/security/pin-lock-on-resume.xml +++ b/journeys/security/pin-lock-on-resume.xml @@ -43,7 +43,6 @@ 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 Confirm screen opens (title "Confirm", amount testTag "ReviewAmount") showing 10 000 sats and the Receive sheet (testTag "ReceiveScreen") is gone - Press back and verify the Send Amount screen (testTag "SendNumberField") shows 10 000 sats Press back to close the Send sheet and verify the wallet home screen is visible diff --git a/journeys/send/scan-onchain-amount-to-confirm.xml b/journeys/send/scan-onchain-amount-to-confirm.xml index 82b20f8a5a..0bca73ff6b 100644 --- a/journeys/send/scan-onchain-amount-to-confirm.xml +++ b/journeys/send/scan-onchain-amount-to-confirm.xml @@ -1,8 +1,9 @@ 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 Back from Confirm - opens Amount with the same amount so it can still be edited, and that tapping the amount on Confirm does the same. Nothing is broadcast. + 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 @@ -15,18 +16,13 @@ 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 Back (testTag "NavigationBack") - Verify the Send Amount screen (testTag "SendNumberField") is visible with 10 000 sats - Tap Continue (testTag "ContinueAmount") and verify the Confirm screen shows 10 000 again - Tap Back (testTag "NavigationBack") and verify the Send Amount screen is visible, then tap Back (testTag "NavigationBack") and verify the wallet home screen is visible without sending - Run adb shell am start -a android.intent.action.VIEW -d "bitcoin:<regtest address>?amount=0.0001" to.bitkit.dev and verify the Confirm screen shows 10 000 - Run adb shell input keyevent KEYCODE_BACK and verify the Send Amount screen (testTag "SendNumberField") is visible with 10 000 sats rather than the sheet closing - Run adb shell input keyevent KEYCODE_BACK and verify the Send sheet closes without sending + 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") on this Confirm, which has no Amount screen beneath it, and verify the Send Amount screen (testTag "SendNumberField") opens with 10 000 sats - Tap Continue (testTag "ContinueAmount") and verify the Confirm screen shows 10 000 again - Tap Back (testTag "NavigationBack") and verify the Send Amount screen shows 10 000, then tap Back (testTag "NavigationBack") and verify the recipient field still holds the URI; close the sheet without sending + 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 diff --git a/journeys/usdt-payment-links-and-scans.xml b/journeys/usdt-payment-links-and-scans.xml index 7a4b5d85df..278a9173d7 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 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 amount screen stays open with the same insufficient-balance or fee error that tapping Review shows. Go back to the recipient and continue again; verify the review is not requested again until Review is tapped. Verify no payment occurs without approval. + 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 amount screen stays open with the same insufficient-balance or fee error that tapping Review shows. Go back to the recipient and continue again; verify the review is not requested again until Review is tapped. Verify the amount is fixed: on the amount screen the number pad and MAX are disabled, 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. 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 amount screen stays open with the copied recipient and amount and the quote error. 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. 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. From a1fff0f6afccd603691e109bb4b37173d195b771 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 16:35:07 -0300 Subject: [PATCH 14/26] fix: never open amount for a scanned onchain amount Co-Authored-By: Claude Opus 5.5 --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 23 +++++++++++++++---- .../viewmodels/AppViewModelSendFlowTest.kt | 22 ++++++++++-------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 77d3b57187..a9e8aa3d05 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -5400,7 +5400,7 @@ class AppViewModel @Inject constructor( return } - if (amount > 0u && openOnchainConfirmForInvoiceAmount(amount, fromMainScanner, walletGeneration)) return + if (amount > 0u && routeOnchainInvoiceAmount(amount, fromMainScanner, walletGeneration)) return Logger.info( when (amount > 0u) { @@ -5413,17 +5413,30 @@ class AppViewModel @Inject constructor( navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount, walletGeneration) } - private suspend fun openOnchainConfirmForInvoiceAmount( + /** + * 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 + } if (!validateAmount(amount)) return false - val isHardware = _sendUiState.value.hardwareWalletId != null - if (!isHardware && !settingsStore.data.first().coinSelectAuto) return false requireCurrentPaymentPreparation(walletGeneration) - if (isHardware && !prepareHardwareSendFee()) return false + // 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) diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index e0f7706c24..cd69be206d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9359,19 +9359,22 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `main scanner onchain scan with dust amount opens amount sheet`() = 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() - assertEquals(Sheet.Send(SendRoute.Amount), sut.currentSheet.value) - assertFalse(sut.sendUiState.value.isAmountEditable) + 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 amount sheet for manual coin selection`() = 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) @@ -9379,7 +9382,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.onScannerSheetResult(input) advanceUntilIdle() - assertEquals(Sheet.Send(SendRoute.Amount), sut.currentSheet.value) + assertEquals(Sheet.Send(SendRoute.Confirm), sut.currentSheet.value) assertEquals(10_000uL, sut.sendUiState.value.amount) assertFalse(sut.sendUiState.value.isAmountEditable) @@ -9426,7 +9429,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() { } @Test - fun `hardware send sheet onchain address opens amount when its fee estimate fails`() = 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())) @@ -9438,10 +9441,11 @@ class AppViewModelSendFlowTest : BaseUnitTest() { sut.setSendEvent(SendEvent.AddressContinue(input)) advanceUntilIdle() - assertEquals(SendEffect.NavigateToAmount, awaitItem()) - cancelAndIgnoreRemainingEvents() + expectNoEvents() } - assertFalse(sut.sendUiState.value.isAmountEditable) + val toasts = argumentCaptor() + verify(toastManager, atLeast(1)).enqueue(toasts.capture()) + assertTrue(toasts.allValues.isNotEmpty()) } private fun stubHardwareFunding() { From 2f37b8514710bc92af0ac0f1fbdc49413de137a4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 16:35:07 -0300 Subject: [PATCH 15/26] fix: open the usdt review for a scanned amount Co-Authored-By: Claude Opus 5.5 --- .../screens/wallets/usdt/UsdtSendContent.kt | 24 +++++++++++++------ .../screens/wallets/usdt/UsdtWalletScreen.kt | 8 ++++--- 2 files changed, 22 insertions(+), 10 deletions(-) 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..68c7f2838c 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( @@ -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,12 +655,18 @@ 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) PrimaryButton( 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 061a45da3a..953f2d9077 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 @@ -157,11 +157,11 @@ fun UsdtWalletScreen( state.busy -> Unit state.authenticationRequired -> viewModel.cancelAuthentication() state.submitted -> dismiss() - state.quote != null && isAmountFixed -> { + state.quote != null -> { viewModel.edit() - page = UsdtPage.MANUAL + // Amount cannot change a fixed amount, so Back leaves it as well. + if (isAmountFixed) page = UsdtPage.MANUAL } - state.quote != null -> viewModel.edit() page in listOf(UsdtPage.AMOUNT, UsdtPage.MANUAL) -> { viewModel.edit() page = if (page == UsdtPage.AMOUNT) UsdtPage.MANUAL else UsdtPage.RECIPIENT @@ -285,6 +285,7 @@ fun UsdtWalletScreen( amount = request.amount?.let { usdtFormatAmount(it) } ?: amount isAmountFixed = request.amount != null page = UsdtPage.AMOUNT + request.amount?.let { viewModel.quote(recipient, amount, destination) } } }, onPageChange = { @@ -304,6 +305,7 @@ fun UsdtWalletScreen( onDone = dismiss, hideBalance = settings.hideBalance, amountEditable = !isAmountFixed, + isScannedAmount = isAmountFixed, onDetails = { selectedTransferId = state.quote?.id dismiss() From 71fac20b51e23136212d53de641a3cd43c4a6844 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 16:43:48 -0300 Subject: [PATCH 16/26] fix: title the fixed usdt review as review Co-Authored-By: Claude Opus 5.5 --- .../java/to/bitkit/ui/screens/wallets/usdt/UsdtSendContent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 68c7f2838c..c8c5d5ee09 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 @@ -195,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 } From 22913e7b527bfc29837f15df6f7fda72999820e1 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 16:44:21 -0300 Subject: [PATCH 17/26] docs: route journeys for scanned amounts away from amount Co-Authored-By: Claude Opus 5.5 --- journeys/coin-selection/manual-coin-selection-load.xml | 4 ++-- journeys/coin-selection/manual-coin-selection.xml | 4 ++-- journeys/send/scan-onchain-amount-to-confirm.xml | 6 ++++-- journeys/usdt-payment-links-and-scans.xml | 4 ++-- 4 files changed, 10 insertions(+), 8 deletions(-) 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/send/scan-onchain-amount-to-confirm.xml b/journeys/send/scan-onchain-amount-to-confirm.xml index 0bca73ff6b..5ca31e9775 100644 --- a/journeys/send/scan-onchain-amount-to-confirm.xml +++ b/journeys/send/scan-onchain-amount-to-confirm.xml @@ -7,8 +7,8 @@ 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. Manual coin selection keeps going through Amount and then the coin selection screen; - see ../coin-selection/manual-coin-selection.xml. Start on the wallet home screen. + 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"). @@ -25,5 +25,7 @@ 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/usdt-payment-links-and-scans.xml b/journeys/usdt-payment-links-and-scans.xml index 278a9173d7..914b9411fe 100644 --- a/journeys/usdt-payment-links-and-scans.xml +++ b/journeys/usdt-payment-links-and-scans.xml @@ -2,8 +2,8 @@ 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 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 amount screen stays open with the same insufficient-balance or fee error that tapping Review shows. Go back to the recipient and continue again; verify the review is not requested again until Review is tapped. Verify the amount is fixed: on the amount screen the number pad and MAX are disabled, 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. 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 amount screen stays open with the copied recipient and amount and the quote error. 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. 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. From 46cd20977d0acdfd5df663411982b1cc15f94664 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 17:33:08 -0300 Subject: [PATCH 18/26] fix: pay a scanned amount from a hardware wallet that covers it Co-Authored-By: Claude Opus 5.5 --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 4 ++++ .../viewmodels/AppViewModelSendFlowTest.kt | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index a9e8aa3d05..703ae793f2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -5433,6 +5433,10 @@ class AppViewModel @Inject constructor( ) 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. diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index cd69be206d..f8df6d6995 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9448,6 +9448,22 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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) + } + private fun stubHardwareFunding() { hwWallets.value = persistentListOf(hardwareWallet(fundingBalanceSats = 50_000uL)) whenever { hwWalletRepo.maxSpendableFunding(any(), any(), any()) }.thenReturn(Result.success(48_000uL)) From 9152b2d91e188fb8d7dfd0cd77e43ba549f9e570 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 17:33:08 -0300 Subject: [PATCH 19/26] fix: keep the scanned onchain amount when switching to savings Co-Authored-By: Claude Opus 5.5 --- .../main/java/to/bitkit/viewmodels/AppViewModel.kt | 3 +++ .../bitkit/viewmodels/AppViewModelSendFlowTest.kt | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 703ae793f2..9aabaf45de 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -4372,6 +4372,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() diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index f8df6d6995..0bb967774e 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -6455,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) From 0db1c4f1eab2cae6b05246f7a0645e4cfef19126 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 17:33:08 -0300 Subject: [PATCH 20/26] fix: keep a scanned hardware amount on a speed change Co-Authored-By: Claude Opus 5.5 --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 3 ++- .../viewmodels/AppViewModelSendFlowTest.kt | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 9aabaf45de..03deb4ee93 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -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 -> diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt index 0bb967774e..4f6903cce9 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt @@ -9477,6 +9477,25 @@ class AppViewModelSendFlowTest : BaseUnitTest() { 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)) From a4dcc73f4819d7d6b68f1744812ffb753bfac456 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 17:33:08 -0300 Subject: [PATCH 21/26] fix: keep a scanned usdt amount fixed after back and ignore zero Co-Authored-By: Claude Opus 5.5 --- .../screens/wallets/usdt/UsdtWalletScreen.kt | 22 ++++++++++++++----- .../wallets/usdt/UsdtWalletScreenTest.kt | 14 ++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) 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 953f2d9077..c5bd2e1e22 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 @@ -135,7 +135,7 @@ fun UsdtWalletScreen( var recipient by rememberSaveable { mutableStateOf(initialRecipient) } var amount by rememberSaveable { mutableStateOf(initialAmount) } // A scanned or linked request that names an amount fixes it; the payee set it. - var isAmountFixed by rememberSaveable { mutableStateOf(initialAmount.isNotEmpty()) } + var isAmountFixed by rememberSaveable { mutableStateOf(isPositiveUsdtAmount(initialAmount)) } var maximumAmount by rememberSaveable { mutableStateOf(null) } val destinations by viewModel.destinations.collectAsStateWithLifecycle() var destination by rememberSaveable { mutableStateOf(UsdtDestination.ARBITRUM) } @@ -281,11 +281,13 @@ fun UsdtWalletScreen( onRecipientContinue = { value -> viewModel.validateRecipient(value, destination) { request -> if (request.chainId != null) destination = UsdtDestination.ARBITRUM + val requestAmount = request.amount?.let { usdtFormatAmount(it) } + val isSameRecipient = request.recipient == recipient + isAmountFixed = isUsdtAmountFixed(requestAmount, isAmountFixed, isSameRecipient) recipient = request.recipient - amount = request.amount?.let { usdtFormatAmount(it) } ?: amount - isAmountFixed = request.amount != null + amount = requestAmount ?: amount page = UsdtPage.AMOUNT - request.amount?.let { viewModel.quote(recipient, amount, destination) } + if (isAmountFixed) viewModel.quote(recipient, amount, destination) } }, onPageChange = { @@ -344,7 +346,17 @@ private data class InitialUsdtPayment( /** 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() && runCatching { PaykitAmount.parseInput(PaykitAsset.USDT, amount) }.isSuccess + recipient.isNotEmpty() && isPositiveUsdtAmount(amount) + +internal fun isPositiveUsdtAmount(amount: String): Boolean = + runCatching { PaykitAmount.parseInput(PaykitAsset.USDT, amount) }.isSuccess + +/** + * A request that names a positive amount fixes it. Continuing with the same recipient and no amount, as after + * Back from review, keeps an earlier fixed amount. + */ +internal fun isUsdtAmountFixed(requestAmount: String?, wasFixed: Boolean, isSameRecipient: Boolean): Boolean = + requestAmount?.let(::isPositiveUsdtAmount) ?: (wasFixed && isSameRecipient) @Composable internal fun rememberUsdtPage(initialRecipient: String, state: UsdtSendState): MutableState { 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 index 04bdba14bd..35d32c9d0e 100644 --- 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 @@ -25,4 +25,18 @@ class UsdtWalletScreenTest { fun `initial payment without recipient is not quoted`() { assertFalse(shouldQuoteInitialUsdtPayment("", "5")) } + + @Test + fun `a positive requested amount is fixed and zero is not`() { + assertTrue(isUsdtAmountFixed(requestAmount = "0.010001", wasFixed = false, isSameRecipient = false)) + assertFalse(isUsdtAmountFixed(requestAmount = "0", wasFixed = true, isSameRecipient = true)) + assertFalse(isPositiveUsdtAmount("0")) + } + + @Test + fun `continuing with the same recipient keeps a fixed amount`() { + assertTrue(isUsdtAmountFixed(requestAmount = null, wasFixed = true, isSameRecipient = true)) + assertFalse(isUsdtAmountFixed(requestAmount = null, wasFixed = true, isSameRecipient = false)) + assertFalse(isUsdtAmountFixed(requestAmount = null, wasFixed = false, isSameRecipient = true)) + } } From 9a7180b6ff04bd4682a9130133a9e549a2ddbc64 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 17:33:08 -0300 Subject: [PATCH 22/26] fix: offer a usdt quote retry when none is in flight Co-Authored-By: Claude Opus 5.5 --- .../to/bitkit/ui/screens/wallets/usdt/UsdtSendContent.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 c8c5d5ee09..59c0da90db 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 @@ -667,8 +667,9 @@ private fun UsdtConfirmationLoading( isPaymentRequest = isPaymentRequest, modifier = Modifier.weight(1f), ) - state.error?.let { - BodyM(state.errorMessage(it), color = Colors.Brand) + state.error?.let { BodyM(state.errorMessage(it), color = Colors.Brand) } + // 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, From d00e77c56445fdbd39b019d5b972263907005149 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 17:37:15 -0300 Subject: [PATCH 23/26] docs: cover back and zero amounts in the usdt journey Co-Authored-By: Claude Opus 5.5 --- journeys/usdt-payment-links-and-scans.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/journeys/usdt-payment-links-and-scans.xml b/journeys/usdt-payment-links-and-scans.xml index 914b9411fe..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 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. Verify no payment occurs without approval. + 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, and that Back from review returns to the amount screen. + 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. From 01fb2a6c806063dd510c3c30330d1f56d3f67733 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 18:34:15 -0300 Subject: [PATCH 24/26] fix: tie a fixed usdt amount to the recipient that set it Co-Authored-By: Claude Opus 5.5 --- .../screens/wallets/usdt/UsdtWalletScreen.kt | 27 ++++++++++++------- .../wallets/usdt/UsdtWalletScreenTest.kt | 22 ++++++++++----- 2 files changed, 34 insertions(+), 15 deletions(-) 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 c5bd2e1e22..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 @@ -134,8 +134,11 @@ fun UsdtWalletScreen( var selectedTransferId by rememberSaveable(initialTransferId) { mutableStateOf(initialTransferId) } var recipient by rememberSaveable { mutableStateOf(initialRecipient) } var amount by rememberSaveable { mutableStateOf(initialAmount) } - // A scanned or linked request that names an amount fixes it; the payee set it. - var isAmountFixed by rememberSaveable { mutableStateOf(isPositiveUsdtAmount(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) } @@ -205,6 +208,7 @@ fun UsdtWalletScreen( app?.requestUsdtEnable { recipient = "" amount = "" + fixedAmountRecipient = null viewModel.edit() page = UsdtPage.RECIPIENT } @@ -282,12 +286,12 @@ fun UsdtWalletScreen( viewModel.validateRecipient(value, destination) { request -> if (request.chainId != null) destination = UsdtDestination.ARBITRUM val requestAmount = request.amount?.let { usdtFormatAmount(it) } - val isSameRecipient = request.recipient == recipient - isAmountFixed = isUsdtAmountFixed(requestAmount, isAmountFixed, isSameRecipient) + fixedAmountRecipient = + fixedUsdtAmountRecipient(request.recipient, requestAmount, fixedAmountRecipient) recipient = request.recipient amount = requestAmount ?: amount page = UsdtPage.AMOUNT - if (isAmountFixed) viewModel.quote(recipient, amount, destination) + if (fixedAmountRecipient != null) viewModel.quote(recipient, amount, destination) } }, onPageChange = { @@ -352,11 +356,16 @@ internal fun isPositiveUsdtAmount(amount: String): Boolean = runCatching { PaykitAmount.parseInput(PaykitAsset.USDT, amount) }.isSuccess /** - * A request that names a positive amount fixes it. Continuing with the same recipient and no amount, as after - * Back from review, keeps an earlier fixed amount. + * 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 isUsdtAmountFixed(requestAmount: String?, wasFixed: Boolean, isSameRecipient: Boolean): Boolean = - requestAmount?.let(::isPositiveUsdtAmount) ?: (wasFixed && isSameRecipient) +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 { 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 index 35d32c9d0e..6ebb84303c 100644 --- 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 @@ -1,7 +1,9 @@ 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 { @@ -26,17 +28,25 @@ class UsdtWalletScreenTest { assertFalse(shouldQuoteInitialUsdtPayment("", "5")) } + private val otherRecipient = "0x2222222222222222222222222222222222222222" + @Test fun `a positive requested amount is fixed and zero is not`() { - assertTrue(isUsdtAmountFixed(requestAmount = "0.010001", wasFixed = false, isSameRecipient = false)) - assertFalse(isUsdtAmountFixed(requestAmount = "0", wasFixed = true, isSameRecipient = true)) + assertEquals(recipient, fixedUsdtAmountRecipient(recipient, requestAmount = "0.010001", fixedRecipient = null)) + assertNull(fixedUsdtAmountRecipient(recipient, requestAmount = "0", fixedRecipient = recipient)) assertFalse(isPositiveUsdtAmount("0")) } @Test - fun `continuing with the same recipient keeps a fixed amount`() { - assertTrue(isUsdtAmountFixed(requestAmount = null, wasFixed = true, isSameRecipient = true)) - assertFalse(isUsdtAmountFixed(requestAmount = null, wasFixed = true, isSameRecipient = false)) - assertFalse(isUsdtAmountFixed(requestAmount = null, wasFixed = false, isSameRecipient = true)) + 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)) } } From 156f029b7ccd99ba88610ac1518053c770d84511 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 18:44:35 -0300 Subject: [PATCH 25/26] fix: space the usdt review error above try again Co-Authored-By: Claude Opus 5.5 --- .../to/bitkit/ui/screens/wallets/usdt/UsdtSendContent.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 59c0da90db..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 @@ -667,7 +667,10 @@ private fun UsdtConfirmationLoading( isPaymentRequest = isPaymentRequest, modifier = Modifier.weight(1f), ) - state.error?.let { BodyM(state.errorMessage(it), color = Colors.Brand) } + state.error?.let { + 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( From 6a85db25630eded9b03d08ee8f460acda333b905 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 4 Nov 2026 19:37:29 -0300 Subject: [PATCH 26/26] test: drop the confirm amount tap compose test Co-Authored-By: Claude Opus 5.5 --- .../wallets/send/SendConfirmNavigationTest.kt | 51 ------------------- 1 file changed, 51 deletions(-) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmNavigationTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmNavigationTest.kt index 2b5796906c..d5ebb95538 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmNavigationTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/wallets/send/SendConfirmNavigationTest.kt @@ -169,57 +169,6 @@ class SendConfirmNavigationTest { } } - @Test - fun tappingAFixedScannedAmountDoesNotOpenAmount() { - val state = mutableStateOf(SendUiState(amount = 10_000u, isAmountInputValid = true, fixedOnchainAmount = 10_000u)) - val events = mutableListOf() - composeTestRule.setContent { - val context = LocalContext.current - val settings = viewModel { - SettingsViewModel( - context, - settingsStore, - pubkyRepo, - contactPaymentSettingsRepo, - widgetsStore, - widgetsRepo, - usdtPreferences - ) - } - AppThemeSurface { - CompositionLocalProvider( - LocalSettingsViewModel provides settings, - LocalInspectionMode provides true, - ) { - val navController = rememberNavController() - NavHost(navController = navController, startDestination = SendRoute.Confirm) { - composable { entry -> - SendConfirmScreen( - savedStateHandle = entry.savedStateHandle, - uiState = state.value, - isNodeRunning = true, - canAutoStart = false, - canGoBack = false, - onBack = {}, - onEvent = { events += it }, - onClickAddTag = {}, - onClickTag = {}, - onNavigateToPin = {}, - ) - } - } - } - } - } - - composeTestRule.onNodeWithTag("ReviewAmount").performClick() - composeTestRule.runOnIdle { assertEquals(false, SendEvent.BackToAmount in events) } - - composeTestRule.runOnIdle { state.value = state.value.copy(fixedOnchainAmount = null) } - composeTestRule.onNodeWithTag("ReviewAmount").performClick() - composeTestRule.runOnIdle { assertEquals(true, SendEvent.BackToAmount in events) } - } - private fun swipeToConfirm() { val width = composeTestRule.onRoot().fetchSemanticsNode().size.width composeTestRule.onNodeWithTag("GRAB").performTouchInput {