Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -135,6 +137,12 @@ fun UsdtWalletScreen(
var maximumAmount by rememberSaveable { mutableStateOf<String?>(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) {
Expand Down Expand Up @@ -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<UsdtPage> {
val paymentInProgress = state.submitted || (state.busy && state.quote != null)
Expand Down
32 changes: 25 additions & 7 deletions app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,23 @@ 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<SendRoute.Confirm> { inclusive = 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 {
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 } }
}
Expand Down Expand Up @@ -491,20 +508,21 @@ 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,
isNodeRunning = uiState.hardwareWalletId != null ||
lightningState.nodeLifecycleState.isRunning(),
canAutoStart = !isOffline && !shouldShowSyncOverlay,
syncNodeVariant = syncNodeVariant,
canGoBack = navController.previousBackStackEntry != null,
onBack = {
val didPopToAmount = navController.popBackStack(SendRoute.Amount, inclusive = false)
if (!didPopToAmount && !navController.popBackStack()) {
appViewModel.hideSheet()
}
},
canGoBack = navController.previousBackStackEntry != null || uiState.canReturnToAmount,
onBack = onConfirmBack,
onEvent = { e -> appViewModel.setSendEvent(e) },
onClickAddTag = { navController.navigateTo(SendRoute.AddTag) },
onClickTag = { tag -> appViewModel.removeTag(tag) },
Expand Down
27 changes: 27 additions & 0 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5399,6 +5399,8 @@ class AppViewModel @Inject constructor(
return
}

if (amount > 0u && openOnchainConfirmForInvoiceAmount(amount, fromMainScanner, walletGeneration)) return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs changing before merge: E2E send shard breaks: e2e main still expects Amount for BIP21 with amount

This change makes @send_1 and @send_2 on bitkit-e2e-tests main fail, and nothing lets CI pick up an updated spec. How the E2E workflow behaves: - .github/workflows/e2e.yml runs on every non-draft PR that touches app/**. - default-feature-branch resolves to an e2e branch with the same name as the app branch, and falls back to main otherwise. - e2e-status fails when any shard fails. - synonymdev/bitkit-e2e-tests has no fix/scan-amount-to-confirm branch (gh api repos/synonymdev/bitkit-e2e-tests/branches/fix%2Fscan-amount-to-confirm returns 404), and the PR body has no Companion: line. What the specs on e2e main expect: - send.e2e.ts:157-169 (@send_1) enters bitcoin:bcrt1q07x3…?amount=0.0001 and taps AddressContinue. It then calls verifyAmountToSend(10_000) and waits for ContinueAmount. - send.e2e.ts:356-366 (@send_2 @ios_gate) enters an on-chain 0.0002 BIP21. Its 200 000 sat Lightning invoice is above Spending. It then waits for AssetButton-savings and taps ContinueAmount. With this PR, both inputs reach openOnchainConfirmForInvoiceAmount (AppViewModel.kt:5402) and open Confirm instead. The PR's own test send sheet onchain address with payable amount navigates to confirm asserts exactly that. How I confirmed it: I downloaded send.e2e.ts from e2e main and traced both inputs through onScanOnchain. I also checked the branch lookup and the workflow's ref resolution. The PR's live send shard (run 41648112580) had not finished when I checked at 14:40 UTC, so I have not seen the failure itself. The author's Gaps section predicts the same break. Once this merges, the send shard fails on every later Android PR that touches app/** until e2e main is updated. Fix: push the spec update to fix/scan-amount-to-confirm in bitkit-e2e-tests so this PR's E2E run picks it up, link it as Companion:, and merge the two in lockstep. @send_2 is also an iOS gate, so coordinate with the iOS twin too.


Logger.info(
when (amount > 0u) {
true -> "Found amount in invoice, proceeding to edit amount"
Expand All @@ -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.
*/
Comment on lines +5415 to +5418

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: KDoc on a private function

openOnchainConfirmForInvoiceAmount is private but has a KDoc block. CLAUDE.md says "NEVER add code comments to private functions".

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

worth doing, does not block: Hardware-wallet direct-Confirm path has no test

openOnchainConfirmForInvoiceAmount (AppViewModel.kt:5426-5430) has one new branch that does real work before navigating. For a hardware wallet it calls prepareHardwareSendFee(). If that fails, it shows the error toast and falls through to Amount. None of the added tests sets a hardware wallet. The PR also says the Trezor flow was not driven on a device. The existing suite already stubs hwWalletRepo.estimateFundingMiningFee (for example AppViewModelSendFlowTest.kt:6489). That makes two cheap tests possible: - A successful estimate opens Confirm with the fee in onchainFeeUi. - A failed estimate opens Amount.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

worth doing, does not block: Tapping the amount on direct Confirm does nothing

Open a funded BIP21 request with an amount, then tap the amount on Confirm. Nothing happens: BackToAmount still emits PopBack(Amount), but this shortcut creates no Amount entry. The amount tap needs the same fallback as the Back arrow.

refreshOnchainSendIfNeeded(walletGeneration)
return true
}

private suspend fun hardwareMaxSpendable(
walletId: String,
address: String,
Expand Down Expand Up @@ -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,
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"))
}
}
46 changes: 46 additions & 0 deletions app/src/test/java/to/bitkit/ui/sheets/SendSheetNavigationTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -47,6 +48,51 @@ 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<Context>()).apply {
navigatorProvider.addNavigator(ComposeNavigator())
graph = createGraph(startDestination = start) {
composable<SendRoute.Recipient> {}
composable<SendRoute.Confirm> {}
composable<SendRoute.Amount> {}
}
}
if (start != SendRoute.Confirm) navController.navigateTo(SendRoute.Confirm)

navController.navigateToAmountFromConfirm()

assertTrue(requireNotNull(navController.currentDestination).hasRoute<SendRoute.Amount>())
val previous = navController.previousBackStackEntry?.destination
if (start == SendRoute.Confirm) {
assertNull(previous)
} else {
assertTrue(requireNotNull(previous).hasRoute<SendRoute.Recipient>())
}
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<Context>()).apply {
navigatorProvider.addNavigator(ComposeNavigator())
graph = createGraph(startDestination = SendRoute.Confirm) {
composable<SendRoute.Confirm> {}
composable<SendRoute.Amount> {}
}
}

val handled = navController.backFromConfirm(canReturnToAmount)

assertEquals(canReturnToAmount, handled)
assertEquals(canReturnToAmount, navController.currentDestination?.hasRoute<SendRoute.Amount>() == true)
}
}

@Test
fun `pending clears earlier send screens after prepared amount review`() {
for (start in listOf(SendRoute.Confirm, SendRoute.ContactSelect, SendRoute.Amount)) {
Expand Down
125 changes: 125 additions & 0 deletions app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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<Result<PublicPaykitPaymentResult>>()
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
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1763.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading