Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5e95204
fix: open review for scanned requests with an amount
jvsena42 Nov 3, 2026
1d280b6
fix: route system back on direct confirm to amount
jvsena42 Nov 3, 2026
5a8cda7
fix: open amount when tapping it on a direct confirm
jvsena42 Nov 4, 2026
2017ebd
test: cover hardware fee estimate on direct confirm
jvsena42 Nov 4, 2026
fa34ec9
chore: drop kdoc from private confirm helper
jvsena42 Nov 4, 2026
c15f207
docs: update pin and usdt journeys for direct review
jvsena42 Nov 4, 2026
bdb1593
docs: drive the amount tap in the scan to confirm journey
jvsena42 Nov 4, 2026
0a1c83a
docs: check confirm stays hidden behind the pin pad
jvsena42 Nov 4, 2026
cfee6e4
docs: keep toolbar back first on the link-opened confirm
jvsena42 Nov 4, 2026
4cc1eca
fix: keep back on a direct confirm from opening amount
jvsena42 Nov 4, 2026
4edce34
fix: lock the amount of a scanned onchain request
jvsena42 Nov 4, 2026
f2cc6f9
fix: lock the amount of a scanned usdt request
jvsena42 Nov 4, 2026
7376f15
docs: update journeys for fixed scanned amounts
jvsena42 Nov 4, 2026
a1fff0f
fix: never open amount for a scanned onchain amount
jvsena42 Nov 4, 2026
2f37b85
fix: open the usdt review for a scanned amount
jvsena42 Nov 4, 2026
71fac20
fix: title the fixed usdt review as review
jvsena42 Nov 4, 2026
22913e7
docs: route journeys for scanned amounts away from amount
jvsena42 Nov 4, 2026
46cd209
fix: pay a scanned amount from a hardware wallet that covers it
jvsena42 Nov 4, 2026
9152b2d
fix: keep the scanned onchain amount when switching to savings
jvsena42 Nov 4, 2026
0db1c4f
fix: keep a scanned hardware amount on a speed change
jvsena42 Nov 4, 2026
a4dcc73
fix: keep a scanned usdt amount fixed after back and ignore zero
jvsena42 Nov 4, 2026
9a7180b
fix: offer a usdt quote retry when none is in flight
jvsena42 Nov 4, 2026
d00e77c
docs: cover back and zero amounts in the usdt journey
jvsena42 Nov 4, 2026
01fb2a6
fix: tie a fixed usdt amount to the recipient that set it
jvsena42 Nov 4, 2026
156f029
fix: space the usdt review error above try again
jvsena42 Nov 4, 2026
6a85db2
test: drop the confirm amount tap compose test
jvsena42 Nov 4, 2026
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 @@ -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,
Expand Down Expand Up @@ -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)
Comment thread
jvsena42 marked this conversation as resolved.
Resolved
if (showSuccess && completed && quote != null) {
NewTransactionSheetView(
details = NewTransactionSheetDetails(
Expand Down Expand Up @@ -193,7 +195,7 @@ internal fun UsdtPaymentContent(
showSubmitted && submissionError != null -> R.string.common__error
showSubmitted -> R.string.usdt__submitted
(quote != null || preparingConfirmation) && isPaymentRequest -> R.string.wallet__payment_request
quote != null -> R.string.wallet__send_review
quote != null || preparingConfirmation -> R.string.wallet__send_review
page == UsdtPage.AMOUNT -> R.string.usdt__amount
else -> R.string.usdt__send_title
}
Expand Down Expand Up @@ -235,6 +237,7 @@ internal fun UsdtPaymentContent(
state,
fundingSource,
onReview,
isPaymentRequest,
)
page == UsdtPage.AMOUNT -> UsdtAmountContent(
amount,
Expand Down Expand Up @@ -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" })
Expand All @@ -651,14 +655,24 @@ private fun UsdtConfirmationLoading(
Box(modifier = Modifier.align(Alignment.End)) { fundingSource() }
}
VerticalSpacer(24.dp)
PaymentRequestSummary(
profile = contact,
note = note?.trim()?.takeIf { it.isNotEmpty() },
accentColor = Colors.Usdt,
if (isPaymentRequest) {
PaymentRequestSummary(
profile = contact,
note = note?.trim()?.takeIf { it.isNotEmpty() },
accentColor = Colors.Usdt,
)
}
PaymentReviewIllustration(
swipeProgress = { 0f },
isPaymentRequest = isPaymentRequest,
modifier = Modifier.weight(1f),
)
PaymentReviewIllustration(swipeProgress = { 0f }, isPaymentRequest = true, modifier = Modifier.weight(1f))
state.error?.let {
BodyM(state.errorMessage(it), color = Colors.Brand)
BodyS(state.errorMessage(it), color = Colors.Brand, modifier = Modifier.testTag("UsdtError"))
VerticalSpacer(16.dp)
}
// Also offered without an error: a review restored after process death has no quote in flight.
if (state.error != null || !state.busy) {
PrimaryButton(
text = stringResource(R.string.common__try_again),
onClick = onRetry,
Expand Down
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 @@ -132,9 +134,20 @@ fun UsdtWalletScreen(
var selectedTransferId by rememberSaveable(initialTransferId) { mutableStateOf(initialTransferId) }
var recipient by rememberSaveable { mutableStateOf(initialRecipient) }
var amount by rememberSaveable { mutableStateOf(initialAmount) }
// The recipient whose scanned or linked request named the amount; the payee set it, so it cannot be edited.
var fixedAmountRecipient by rememberSaveable {
mutableStateOf(initialRecipient.takeIf { isPositiveUsdtAmount(initialAmount) })
}
val isAmountFixed = fixedAmountRecipient != null
var maximumAmount by rememberSaveable { mutableStateOf<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 All @@ -147,7 +160,11 @@ fun UsdtWalletScreen(
state.busy -> Unit
state.authenticationRequired -> viewModel.cancelAuthentication()
state.submitted -> dismiss()
state.quote != null -> viewModel.edit()
state.quote != null -> {
viewModel.edit()
// Amount cannot change a fixed amount, so Back leaves it as well.
if (isAmountFixed) page = UsdtPage.MANUAL
}
Comment thread
jvsena42 marked this conversation as resolved.
Resolved
page in listOf(UsdtPage.AMOUNT, UsdtPage.MANUAL) -> {
viewModel.edit()
page = if (page == UsdtPage.AMOUNT) UsdtPage.MANUAL else UsdtPage.RECIPIENT
Expand Down Expand Up @@ -191,6 +208,7 @@ fun UsdtWalletScreen(
app?.requestUsdtEnable {
recipient = ""
amount = ""
fixedAmountRecipient = null
viewModel.edit()
page = UsdtPage.RECIPIENT
}
Expand Down Expand Up @@ -267,9 +285,13 @@ fun UsdtWalletScreen(
onRecipientContinue = { value ->
viewModel.validateRecipient(value, destination) { request ->
if (request.chainId != null) destination = UsdtDestination.ARBITRUM
val requestAmount = request.amount?.let { usdtFormatAmount(it) }
fixedAmountRecipient =
fixedUsdtAmountRecipient(request.recipient, requestAmount, fixedAmountRecipient)
recipient = request.recipient
amount = request.amount?.let { usdtFormatAmount(it) } ?: amount
amount = requestAmount ?: amount
page = UsdtPage.AMOUNT
if (fixedAmountRecipient != null) viewModel.quote(recipient, amount, destination)
}
},
onPageChange = {
Expand All @@ -288,6 +310,8 @@ fun UsdtWalletScreen(
onConfirm = { viewModel.confirm() }, onRetryRecovery = { viewModel.retryRecovery() },
onDone = dismiss,
hideBalance = settings.hideBalance,
amountEditable = !isAmountFixed,
isScannedAmount = isAmountFixed,
onDetails = {
selectedTransferId = state.quote?.id
dismiss()
Expand All @@ -299,6 +323,50 @@ fun UsdtWalletScreen(
}
}

@Composable
private fun InitialUsdtQuoteEffect(
payment: InitialUsdtPayment,
initialRecipient: String,
initialAmount: String,
onQuote: () -> Unit,
) {
var isRequested by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
if (isRequested) return@LaunchedEffect
isRequested = true
val isInitialPayment = payment.page == UsdtPage.AMOUNT && payment.recipient == initialRecipient &&
payment.amount == initialAmount && payment.destination == UsdtDestination.ARBITRUM && !payment.hasQuote
if (isInitialPayment && shouldQuoteInitialUsdtPayment(initialRecipient, initialAmount)) onQuote()
}
}

private data class InitialUsdtPayment(
val page: UsdtPage,
val recipient: String,
val amount: String,
val destination: UsdtDestination,
val hasQuote: Boolean,
)

/** A payment request with a recipient and a positive amount opens on its review, as tapping Review would. */
internal fun shouldQuoteInitialUsdtPayment(recipient: String, amount: String): Boolean =
recipient.isNotEmpty() && isPositiveUsdtAmount(amount)

internal fun isPositiveUsdtAmount(amount: String): Boolean =
runCatching { PaykitAmount.parseInput(PaykitAsset.USDT, amount) }.isSuccess

/**
* The recipient whose request fixes the amount. A positive requested amount fixes it for that recipient.
* Continuing with the same recipient and no amount, as after Back from review, keeps it; any other recipient
* clears it.
*/
internal fun fixedUsdtAmountRecipient(recipient: String, requestAmount: String?, fixedRecipient: String?): String? =
when {
requestAmount != null -> recipient.takeIf { isPositiveUsdtAmount(requestAmount) }
fixedRecipient.equals(recipient, ignoreCase = true) -> fixedRecipient
else -> null
}

@Composable
internal fun rememberUsdtPage(initialRecipient: String, state: UsdtSendState): MutableState<UsdtPage> {
val paymentInProgress = state.submitted || (state.busy && state.quote != null)
Expand Down
53 changes: 50 additions & 3 deletions app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -4372,6 +4373,9 @@ class AppViewModel @Inject constructor(
fixedContactLightningAmount(current)?.let {
return PaykitAmount(PaykitAsset.BTC, it)
}
} else if (source != SendFundingSource.Usdt) {
// An amount typed on Spending must not replace the payee's on-chain amount.
current.fixedOnchainAmount?.let { return PaykitAmount(PaykitAsset.BTC, it) }
}
val input = activeIncomingPaymentRequest()?.amount ?: if (current.paykitUsesUsdt) {
runCatching { PaykitAmount.parseInput(PaykitAsset.USDT, current.paykitAmount) }.getOrNull()
Expand Down Expand Up @@ -5266,6 +5270,7 @@ class AppViewModel @Inject constructor(
},
hardwareWalletId = hardwareWalletId,
hardwareAvailableSats = selectedMaxSendOnchain.takeIf { hardwareWalletId != null } ?: 0uL,
fixedOnchainAmount = invoice.amountSatoshis.takeIf { it > 0uL && incomingPaymentRequest == null },
Comment thread
jvsena42 marked this conversation as resolved.
Resolved
)
}
updateCanSwitchWallet()
Expand Down Expand Up @@ -5399,6 +5404,8 @@ class AppViewModel @Inject constructor(
return
}

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

Logger.info(
when (amount > 0u) {
true -> "Found amount in invoice, proceeding to edit amount"
Expand All @@ -5410,6 +5417,43 @@ class AppViewModel @Inject constructor(
navigateToSendRoute(fromMainScanner, SendRoute.Amount, SendEffect.NavigateToAmount, walletGeneration)
}

/**
* The payee set the amount, so it opens Confirm and never Amount. Returns false only when the
* request is paid another way. Manual coin selection opens the picker from the swipe.
*/
private suspend fun routeOnchainInvoiceAmount(
amount: ULong,
fromMainScanner: Boolean,
walletGeneration: Long?,
): Boolean {
if (_sendUiState.value.payMethod != SendMethod.ONCHAIN || hasActiveContactPaymentContext()) return false
if (amount <= Defaults.dustLimit.toULong()) {
toast(
type = Toast.ToastType.ERROR,
title = context.getString(R.string.wallet__lnurl_pay__error_min__title),
description = context.getString(R.string.wallet__lnurl_pay__error_min__description)
.replace("{amount}", formatMoneyValue(Defaults.dustLimit.toULong().safe() + 1uL.safe())),
testTag = "AmountTooLowToast",
)
return true
}
// Savings cannot cover it but a hardware wallet can, so pay from that instead of opening Amount.
if (!validateAmount(amount) && _sendUiState.value.hardwareWalletId == null) {
selectHardwareFundingSourceForAmount(amount, walletGeneration)
}
if (!validateAmount(amount)) return false
requireCurrentPaymentPreparation(walletGeneration)
// A failed estimate has shown its error; the user stays where the scan started.
if (!prepareHardwareSendFee()) return true
requireCurrentPaymentPreparation(walletGeneration)

Logger.info("Found payable amount in invoice, proceeding to confirm", context = TAG)
_sendUiState.update { it.copy(isAmountInputValid = true) }
navigateToSendRoute(fromMainScanner, SendRoute.Confirm, SendEffect.NavigateToConfirm, walletGeneration)
Comment thread
jvsena42 marked this conversation as resolved.
Resolved
refreshOnchainSendIfNeeded(walletGeneration)
return true
}

private suspend fun hardwareMaxSpendable(
walletId: String,
address: String,
Expand Down Expand Up @@ -8935,6 +8979,8 @@ data class SendUiState(
val isInitialSubscriptionPayment: Boolean = false,
val initialSubscriptionPaymentAutoStartPending: Boolean = false,
val requiresAmountReview: Boolean = false,
/** The amount of a scanned on-chain request; the payee set it, so it cannot be edited. */
val fixedOnchainAmount: ULong? = null,
val initialSubscriptionPaymentNeedsConfirmation: Boolean = false,
val incomingPaymentRequestId: PaykitPaymentRequestId? = null,
) {
Expand All @@ -8958,7 +9004,8 @@ data class SendUiState(
get() = hardwareWalletId == null || (!onchainFeeUi.isLoading && !onchainFeeUi.requiresRefresh)

val isAmountEditable: Boolean
get() = !isPaymentRequest && fixedLightningAmount == null
get() = !isPaymentRequest && fixedLightningAmount == null &&
!(payMethod == SendMethod.ONCHAIN && fixedOnchainAmount != null)
Comment thread
jvsena42 marked this conversation as resolved.
Resolved

val shouldAutomaticallyPay: Boolean
get() = isInitialSubscriptionPayment && hardwareWalletId == null &&
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package to.bitkit.ui.screens.wallets.usdt

import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue

class UsdtWalletScreenTest {
private val recipient = "0x1111111111111111111111111111111111111111"

@Test
fun `initial payment with recipient and positive amount is quoted`() {
assertTrue(shouldQuoteInitialUsdtPayment(recipient, "0.010001"))
assertTrue(shouldQuoteInitialUsdtPayment(recipient, "5"))
}

@Test
fun `initial payment without a payable amount is not quoted`() {
assertFalse(shouldQuoteInitialUsdtPayment(recipient, ""))
assertFalse(shouldQuoteInitialUsdtPayment(recipient, "0"))
assertFalse(shouldQuoteInitialUsdtPayment(recipient, "abc"))
assertFalse(shouldQuoteInitialUsdtPayment(recipient, "0.0000001"))
}

@Test
fun `initial payment without recipient is not quoted`() {
assertFalse(shouldQuoteInitialUsdtPayment("", "5"))
}

private val otherRecipient = "0x2222222222222222222222222222222222222222"

@Test
fun `a positive requested amount is fixed and zero is not`() {
assertEquals(recipient, fixedUsdtAmountRecipient(recipient, requestAmount = "0.010001", fixedRecipient = null))
assertNull(fixedUsdtAmountRecipient(recipient, requestAmount = "0", fixedRecipient = recipient))
assertFalse(isPositiveUsdtAmount("0"))
}

@Test
fun `continuing with the recipient that fixed the amount keeps it`() {
assertEquals(recipient, fixedUsdtAmountRecipient(recipient, requestAmount = null, fixedRecipient = recipient))
val checksummed = recipient.uppercase().replace("0X", "0x")
assertEquals(recipient, fixedUsdtAmountRecipient(checksummed, requestAmount = null, fixedRecipient = recipient))
}

@Test
fun `another recipient or no earlier fixed amount leaves the amount editable`() {
assertNull(fixedUsdtAmountRecipient(otherRecipient, requestAmount = null, fixedRecipient = recipient))
assertNull(fixedUsdtAmountRecipient(recipient, requestAmount = null, fixedRecipient = null))
}
}
Loading
Loading