diff --git a/Justfile b/Justfile index a0b861ea27..0b42d0dfd7 100644 --- a/Justfile +++ b/Justfile @@ -12,7 +12,7 @@ list: "list" \ "init" \ "compile" \ - "run" \ + "run [docker]" \ "build [TASK]" \ "release" \ "install" \ @@ -45,12 +45,18 @@ init: compile: {{ gradle }} compileDevDebugKotlin -run: +run mode="": #!/usr/bin/env sh set -eu app_id="to.bitkit.dev" app_dir="app/build/outputs/apk/dev/debug" + mode="{{ mode }}" + + if [ -n "$mode" ] && [ "$mode" != "docker" ]; then + echo "usage: just run [docker]" >&2 + exit 1 + fi if ! command -v adb >/dev/null 2>&1; then echo "adb is required to run the app." >&2 @@ -90,8 +96,19 @@ run: fi echo "Using $device_name ($device_id)" + + build_env="" + if [ "$mode" = "docker" ]; then + echo "Forwarding bitkit-docker ports via adb reverse..." + adb -s "$device_id" reverse tcp:60001 tcp:60001 # local Electrum + adb -s "$device_id" reverse tcp:6288 tcp:6288 # local homegate + adb -s "$device_id" reverse tcp:9735 tcp:9735 # local lnd peer + adb -s "$device_id" reverse tcp:3000 tcp:3000 # local lnurl-server + build_env="E2E=true" + fi + echo "Building Debug app..." - {{ gradle }} assembleDevDebug + env $build_env {{ gradle }} assembleDevDebug app_path="$( find "$app_dir" -maxdepth 1 -name '*-universal.apk' -type f \ diff --git a/app/src/main/java/to/bitkit/models/Toast.kt b/app/src/main/java/to/bitkit/models/Toast.kt index a4dc00d990..4ff2cd760d 100644 --- a/app/src/main/java/to/bitkit/models/Toast.kt +++ b/app/src/main/java/to/bitkit/models/Toast.kt @@ -11,6 +11,7 @@ data class Toast( enum class ToastType { SUCCESS, INFO, LIGHTNING, WARNING, ERROR } companion object { + const val VISIBILITY_TIME_SHORT = 1500L const val VISIBILITY_TIME_DEFAULT = 3000L } } diff --git a/app/src/main/java/to/bitkit/ui/components/NumberPad.kt b/app/src/main/java/to/bitkit/ui/components/NumberPad.kt index 765682f2c5..d9f76a7503 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPad.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPad.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.hapticfeedback.HapticFeedbackType @@ -170,6 +171,7 @@ fun NumberPad( viewModel: AmountInputViewModel, modifier: Modifier = Modifier, currencies: CurrencyState = LocalCurrencies.current, + enabled: Boolean = true, type: NumberPadType = viewModel.getNumberPadType(currencies), availableHeight: Dp = defaultHeight, decimalSeparator: String = KEY_DECIMAL, @@ -177,8 +179,8 @@ fun NumberPad( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() NumberPad( - onPress = { key -> viewModel.handleNumberPadInput(key, currencies) }, - modifier = modifier, + onPress = { key -> if (enabled) viewModel.handleNumberPadInput(key, currencies) }, + modifier = modifier.alpha(if (enabled) 1f else 0.5f), type = type, availableHeight = availableHeight, decimalSeparator = decimalSeparator, diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt index 7e92fad64e..ba779be639 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 @@ -27,6 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R import to.bitkit.ext.mockOrder import to.bitkit.models.Toast +import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.appViewModel @@ -46,6 +48,7 @@ import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.AmountInputEffect import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.TransferEffect import to.bitkit.viewmodels.TransferToSpendingUiState @@ -64,12 +67,14 @@ fun SpendingAdvancedScreen( ) { val currentOnOrderCreated by rememberUpdatedState(onOrderCreated) val app = appViewModel ?: return + val context = LocalContext.current val state by viewModel.spendingUiState.collectAsStateWithLifecycle() val order = state.order ?: return val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() var isLoading by remember { mutableStateOf(false) } val transferValues by viewModel.transferValues.collectAsStateWithLifecycle() + val currentMaxLspBalance by rememberUpdatedState(transferValues.maxLspBalance) LaunchedEffect(order.clientBalanceSat) { viewModel.updateTransferValues(order.clientBalanceSat) @@ -79,6 +84,10 @@ fun SpendingAdvancedScreen( viewModel.onReceivingAmountChange(amountUiState.sats) } + LaunchedEffect(transferValues.maxLspBalance) { + amountInputViewModel.setMaxAmount(transferValues.maxLspBalance.toLong()) + } + LaunchedEffect(Unit) { viewModel.transferEffects.collect { effect -> when (effect) { @@ -100,6 +109,20 @@ fun SpendingAdvancedScreen( } } + LaunchedEffect(Unit) { + amountInputViewModel.effect.collect { + when (it) { + AmountInputEffect.MaxExceeded -> app.toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.lightning__spending_advanced__error_max__title), + description = context.getString(R.string.lightning__spending_advanced__error_max__description) + .replace("{amount}", currentMaxLspBalance.formatToModernDisplay()), + visibilityTime = Toast.VISIBILITY_TIME_SHORT, + ) + } + } + } + val isValid = transferValues.let { val amount = amountUiState.sats.toULong() amount > 0u && it.maxLspBalance > 0u && amount in it.minLspBalance..it.maxLspBalance diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt index 7de5e1bb8f..7a9fe15e97 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -26,6 +27,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R +import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies import to.bitkit.ui.components.ConnectionIssuesView @@ -47,6 +49,7 @@ import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.AmountInputEffect import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.TransferEffect import to.bitkit.viewmodels.TransferToSpendingUiState @@ -70,6 +73,7 @@ fun SpendingAmountScreen( val isNodeRunning by viewModel.isNodeRunning.collectAsStateWithLifecycle() val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current + val currentMaxAllowedToSend by rememberUpdatedState(uiState.maxAllowedToSend) LaunchedEffect(isOffline) { viewModel.updateLimits() @@ -85,6 +89,18 @@ fun SpendingAmountScreen( } } + LaunchedEffect(Unit) { + amountInputViewModel.effect.collect { + when (it) { + AmountInputEffect.MaxExceeded -> toast( + context.getString(R.string.lightning__spending_amount__error_max__title), + context.getString(R.string.lightning__spending_amount__error_max__description) + .replace("{amount}", currentMaxAllowedToSend.formatToModernDisplay()), + ) + } + } + } + Box { Content( isNodeRunning = isNodeRunning, @@ -99,7 +115,7 @@ fun SpendingAmountScreen( toast( context.getString(R.string.lightning__spending_amount__error_max__title), context.getString(R.string.lightning__spending_amount__error_max__description) - .replace("{amount}", "$max"), + .replace("{amount}", max.formatToModernDisplay()), ) } val cappedQuarter = min(quarter, max) @@ -174,6 +190,10 @@ private fun SpendingAmountNodeRunning( onClickMaxAmount: () -> Unit, onConfirmAmount: () -> Unit, ) { + LaunchedEffect(uiState.maxAllowedToSend) { + amountInputViewModel.setMaxAmount(uiState.maxAllowedToSend) + } + Column( modifier = Modifier .padding(horizontal = 16.dp) @@ -244,6 +264,7 @@ private fun SpendingAmountNodeRunning( NumberPad( viewModel = amountInputViewModel, currencies = currencies, + enabled = !uiState.isLoading, ) PrimaryButton( diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalAmountScreen.kt index d960b32db0..650587cc1d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalAmountScreen.kt @@ -42,6 +42,7 @@ import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent +import to.bitkit.viewmodels.AmountInputEffect import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.previewAmountInputViewModel import kotlin.math.min @@ -63,6 +64,18 @@ fun ExternalAmountScreen( viewModel.onAmountChange(amountUiState.sats) } + LaunchedEffect(uiState.amount.max) { + amountInputViewModel.setMaxAmount(uiState.amount.max) + } + + LaunchedEffect(Unit) { + amountInputViewModel.effect.collect { + when (it) { + AmountInputEffect.MaxExceeded -> viewModel.onMaxExceeded() + } + } + } + Content( amountInputViewModel = amountInputViewModel, amountState = uiState.amount, @@ -167,7 +180,7 @@ private fun Content( PrimaryButton( text = stringResource(R.string.common__continue), onClick = { onContinueClick() }, - enabled = amountUiState.sats != 0L, + enabled = amountUiState.sats in 1..amountState.max, modifier = Modifier.testTag("ExternalAmountContinue") ) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt index 464b73fe2e..4a0a05dae6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt @@ -97,21 +97,20 @@ class ExternalNodeViewModel @Inject constructor( } fun onAmountChange(sats: Long) { - val maxAmount = _uiState.value.amount.max + _uiState.update { it.copy(amount = it.amount.copy(sats = sats)) } + } - if (sats > maxAmount) { - viewModelScope.launch { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.lightning__spending_amount__error_max__title), - description = context.getString(R.string.lightning__spending_amount__error_max__description) - .replace("{amount}", maxAmount.formatToModernDisplay()), - ) - } - return + fun onMaxExceeded() { + val maxAmount = _uiState.value.amount.max + viewModelScope.launch { + ToastEventBus.send( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.lightning__spending_amount__error_max__title), + description = context.getString(R.string.lightning__spending_amount__error_max__description) + .replace("{amount}", maxAmount.formatToModernDisplay()), + visibilityTime = Toast.VISIBILITY_TIME_SHORT, + ) } - - _uiState.update { it.copy(amount = it.amount.copy(sats = sats)) } } fun onAmountContinue() { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt index aa72180333..fed94b10a2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt @@ -56,6 +56,7 @@ import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.viewmodels.AmountInputEffect import to.bitkit.viewmodels.AmountInputUiState import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.LnurlParams @@ -73,6 +74,7 @@ fun SendAmountScreen( onBack: () -> Unit, onEvent: (SendEvent) -> Unit, currencies: CurrencyState = LocalCurrencies.current, + balances: BalanceState = LocalBalances.current, amountInputViewModel: AmountInputViewModel = hiltViewModel(), ) { val app = appViewModel @@ -80,6 +82,22 @@ fun SendAmountScreen( val amountInputUiState: AmountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() val currentOnEvent by rememberUpdatedState(onEvent) + val maxExceededMessage = run { + val lnurl = uiState.lnurl + val lnurlPayMaxExceeded = lnurl is LnurlParams.LnurlPay && + lnurl.data.maxSendableSat().toLong() < + (balances.maxSendLightningSats.safe() - uiState.estimatedRoutingFee.safe()).toLong() + when { + lnurl is LnurlParams.LnurlWithdraw -> + R.string.wallet__lnurl_w_error_max__title to R.string.wallet__lnurl_w_error_max__description + lnurlPayMaxExceeded -> + R.string.wallet__lnurl_pay__error_max__title to R.string.wallet__lnurl_pay__error_max__description + else -> + R.string.wallet__send_amount_exceeded__title to R.string.wallet__send_amount_exceeded__description + } + } + val currentMaxExceededMessage by rememberUpdatedState(maxExceededMessage) + LaunchedEffect(Unit) { if (uiState.amount > 0u) { amountInputViewModel.setSats(uiState.amount.toLong(), currencies) @@ -90,6 +108,23 @@ fun SendAmountScreen( currentOnEvent(SendEvent.AmountChange(amountInputUiState.sats.toULong())) } + LaunchedEffect(Unit) { + amountInputViewModel.effect.collect { + when (it) { + AmountInputEffect.MaxExceeded -> { + val (titleRes, descriptionRes) = currentMaxExceededMessage + app?.toast( + type = Toast.ToastType.WARNING, + title = context.getString(titleRes), + description = context.getString(descriptionRes), + visibilityTime = Toast.VISIBILITY_TIME_SHORT, + testTag = "SendAmountExceededToast", + ) + } + } + } + } + LaunchedEffect(uiState.decodedInvoice, uiState.payMethod) { if (uiState.payMethod == SendMethod.LIGHTNING && uiState.decodedInvoice != null) { currentOnEvent(SendEvent.EstimateMaxRoutingFee) @@ -203,6 +238,15 @@ private fun SendAmountNodeRunning( } } + val maxAllowed = when (val lnurl = uiState.lnurl) { + is LnurlParams.LnurlPay -> minOf(lnurl.data.maxSendableSat().toLong(), availableAmount) + else -> availableAmount + } + + LaunchedEffect(maxAllowed) { + amountInputViewModel.setMaxAmount(maxAllowed) + } + Column( modifier = Modifier.padding(horizontal = 16.dp) ) { diff --git a/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt index 2cf3309df1..7a4e0b24f1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt @@ -6,8 +6,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -52,8 +55,16 @@ class AmountInputViewModel @Inject constructor( private val _uiState = MutableStateFlow(AmountInputUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _effect = MutableSharedFlow(extraBufferCapacity = 1) + val effect: SharedFlow = _effect.asSharedFlow() + + private var maxAmount: Long = MAX_AMOUNT private var rawInputText: String = "" + fun setMaxAmount(amount: Long) { + maxAmount = if (amount > 0) amount.coerceAtMost(MAX_AMOUNT) else MAX_AMOUNT + } + fun handleNumberPadInput( key: String, currencyState: CurrencyState, @@ -74,7 +85,7 @@ class AmountInputViewModel @Inject constructor( if (primaryDisplay == PrimaryDisplay.BITCOIN && isModern) { val newAmount = convertToSats(newText, primaryDisplay, isModern = true) - if (newAmount <= MAX_AMOUNT) { + if (key == KEY_DELETE || newAmount <= maxAmount) { rawInputText = newText _uiState.update { it.copy( @@ -84,14 +95,14 @@ class AmountInputViewModel @Inject constructor( ) } } else { - // Block input when limit exceeded + emitMaxExceeded() triggerErrorState(key) } } else { // For decimal input, check limits before updating state if (newText.isNotEmpty()) { val newAmount = convertToSats(newText, primaryDisplay, isModern) - if (newAmount <= MAX_AMOUNT) { + if (key == KEY_DELETE || newAmount <= maxAmount) { // Update both raw input and display text rawInputText = newText _uiState.update { @@ -106,7 +117,7 @@ class AmountInputViewModel @Inject constructor( ) } } else { - // Block input when limit exceeded + emitMaxExceeded() triggerErrorState(key) } } else { @@ -251,9 +262,16 @@ class AmountInputViewModel @Inject constructor( fun clearInput() { rawInputText = "" + maxAmount = MAX_AMOUNT _uiState.update { AmountInputUiState() } } + private fun emitMaxExceeded() { + if (maxAmount < MAX_AMOUNT) { + _effect.tryEmit(AmountInputEffect.MaxExceeded) + } + } + private fun triggerErrorState(key: String) { _uiState.update { it.copy(errorKey = key) } viewModelScope.launch { @@ -412,6 +430,10 @@ data class AmountInputUiState( val errorKey: String? = null, ) +sealed interface AmountInputEffect { + data object MaxExceeded : AmountInputEffect +} + @SuppressLint("ViewModelConstructorInComposable") @Composable fun previewAmountInputViewModel( diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 4460866030..6e03c75149 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -376,8 +376,18 @@ class TransferViewModel @Inject constructor( availableAmount: ULong, balanceAfterLspFee: ULong, ) { - val liquidity = blocktankRepo.calculateLiquidityOptions(balanceAfterLspFee).getOrNull() - if (liquidity == null || liquidity.maxLspBalanceSat == 0uL) { + // An on-chain balance larger than the LSP's max channel size makes + // calculateLiquidityOptions report maxLspBalanceSat = 0 (the client balance already + // saturates the channel). Clamp the prospective client balance to the LSP's + // maxClientBalanceSat so the spendable amount caps at that limit instead of collapsing + // to zero, leaving the rest of the funds on-chain. + val lspMaxClientBalance = blocktankRepo.blocktankState.value.info?.options?.maxClientBalanceSat + val cappedClientBalance = lspMaxClientBalance + ?.let { max -> minOf(balanceAfterLspFee, max) } + ?: balanceAfterLspFee + + val liquidity = blocktankRepo.calculateLiquidityOptions(cappedClientBalance).getOrNull() + if (liquidity == null || liquidity.maxClientBalanceSat == 0uL) { _spendingUiState.update { it.copy(isLoading = false, maxAllowedToSend = 0) } return } @@ -385,21 +395,22 @@ class TransferViewModel @Inject constructor( val receivingAmount = maxOf(liquidity.defaultLspBalanceSat, liquidity.minLspBalanceSat) blocktankRepo.estimateOrderFee( - spendingBalanceSats = balanceAfterLspFee, + spendingBalanceSats = cappedClientBalance, receivingBalanceSats = receivingAmount, ).onSuccess { estimate -> maxLspFee = estimate.feeSat val lspFees = estimate.networkFeeSat.safe() + estimate.serviceFeeSat.safe() val maxClientBalance = availableAmount.safe() - lspFees.safe() + val maxSend = min( + liquidity.maxClientBalanceSat.toLong(), + maxClientBalance.toLong() + ) _spendingUiState.update { it.copy( - maxAllowedToSend = min( - liquidity.maxClientBalanceSat.toLong(), - maxClientBalance.toLong() - ), + maxAllowedToSend = maxSend, isLoading = false, - balanceAfterFee = availableAmount.toLong(), + balanceAfterFee = maxSend, ) } }.onFailure { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9ba0b7a597..b5d018958e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -301,6 +301,8 @@ Please wait, your funds transfer is in progress. This should take <accent>±10 minutes.</accent> Spendable Onchain Spending + The receiving capacity is currently limited to ₿ {amount}. + Receiving Capacity Maximum Liquidity fee Receiving\n<accent>capacity</accent> The amount you can transfer to your spending balance is currently limited to ₿ {amount}. @@ -1061,11 +1063,15 @@ Received Instant Bitcoin Lightning Startup Error Pay Bitcoin + The amount exceeds this invoice\'s maximum. + Amount Too High The minimum amount for this invoice is ₿ {amount}. Amount Too Low Comment Optional comment to receiver Withdraw + The amount exceeds the maximum you can withdraw. + Amount Too High AvailablE TO WITHDRAW The funds you withdraw will be deposited into your Bitkit spending balance. Withdraw Bitcoin @@ -1116,6 +1122,8 @@ Send Enter an invoice, address, or profile key Bitcoin Amount + The amount exceeds your available balance. + Insufficient balance Available Available (savings) Available (spending) diff --git a/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt index 465809ba20..32edff9dd7 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt @@ -3,7 +3,9 @@ package to.bitkit.viewmodels import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -919,6 +921,205 @@ class AmountInputViewModelTest : BaseUnitTest() { assertTrue("Toggle operation should complete without error", true) } + // MARK: - Max Amount Enforcement Tests + + @Test + fun `max amount blocks input when exceeded`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + viewModel.setMaxAmount(500) + + // Type 50 - should succeed + viewModel.handleNumberPadInput("5", currency) + viewModel.handleNumberPadInput("0", currency) + assertEquals(50L, viewModel.uiState.value.sats) + + // Type 0 to make 500 - should succeed + viewModel.handleNumberPadInput("0", currency) + assertEquals(500L, viewModel.uiState.value.sats) + + // Type 0 to make 5000 - should be blocked + viewModel.handleNumberPadInput("0", currency) + assertEquals(500L, viewModel.uiState.value.sats) + assertNotNull(viewModel.uiState.value.errorKey) + } + + @Test + fun `max amount blocks fiat input when exceeded`() = test { + val currency = mockCurrency(PrimaryDisplay.FIAT) + + viewModel.setMaxAmount(1_000) + + viewModel.handleNumberPadInput("1", currency) + assertEquals("1", viewModel.uiState.value.text) + assertEquals(868L, viewModel.uiState.value.sats) + + viewModel.handleNumberPadInput("0", currency) + assertEquals("1", viewModel.uiState.value.text) + assertEquals(868L, viewModel.uiState.value.sats) + assertNotNull(viewModel.uiState.value.errorKey) + } + + @Test + fun `max exceeded effect is emitted when dynamic limit is hit`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + viewModel.setMaxAmount(100) + + // Type 100 - should succeed + "100".forEach { viewModel.handleNumberPadInput(it.toString(), currency) } + assertEquals(100L, viewModel.uiState.value.sats) + + // Collect effect + var effectReceived = false + val job = backgroundScope.launch(testDispatcher) { + viewModel.effect.collect { + if (it is AmountInputEffect.MaxExceeded) effectReceived = true + } + } + + // Type 0 to make 1000 - should be blocked and emit effect + viewModel.handleNumberPadInput("0", currency) + assertTrue(effectReceived) + + job.cancel() + } + + @Test + fun `no max exceeded effect when hitting global MAX_AMOUNT`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + // Don't set a custom max - use default MAX_AMOUNT + + // Type max amount + "999999999".forEach { viewModel.handleNumberPadInput(it.toString(), currency) } + assertEquals(AmountInputViewModel.MAX_AMOUNT, viewModel.uiState.value.sats) + + // Collect effect + var effectReceived = false + val job = backgroundScope.launch(testDispatcher) { + viewModel.effect.collect { + if (it is AmountInputEffect.MaxExceeded) effectReceived = true + } + } + + // Try to exceed - should be blocked but NOT emit MaxExceeded + viewModel.handleNumberPadInput("0", currency) + assertFalse(effectReceived) + + job.cancel() + } + + @Test + fun `clearInput resets max amount to default`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + viewModel.setMaxAmount(100) + viewModel.handleNumberPadInput("5", currency) + viewModel.clearInput() + + // After clear, max should be reset to MAX_AMOUNT + // Type amount above old max (100) but below MAX_AMOUNT + "500".forEach { viewModel.handleNumberPadInput(it.toString(), currency) } + assertEquals(500L, viewModel.uiState.value.sats) + } + + @Test + fun `dynamic max amount update is respected mid-input`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + viewModel.setMaxAmount(1000) + + // Type 500 - should succeed + "500".forEach { viewModel.handleNumberPadInput(it.toString(), currency) } + assertEquals(500L, viewModel.uiState.value.sats) + + // Lower the max to 300 + viewModel.setMaxAmount(300) + + // Type 0 to make 5000 - should be blocked (above new max of 300) + viewModel.handleNumberPadInput("0", currency) + assertEquals(500L, viewModel.uiState.value.sats) + assertNotNull(viewModel.uiState.value.errorKey) + } + + @Test + fun `delete is allowed when amount is above the cap`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + // Type 50000 under a high cap + "50000".forEach { viewModel.handleNumberPadInput(it.toString(), currency) } + assertEquals(50000L, viewModel.uiState.value.sats) + + // Cap drops below the current amount + viewModel.setMaxAmount(30000) + + // Adding a digit is still blocked + viewModel.handleNumberPadInput("0", currency) + assertEquals(50000L, viewModel.uiState.value.sats) + assertNotNull(viewModel.uiState.value.errorKey) + + // Deleting is allowed even though the result is still above the cap + viewModel.handleNumberPadInput(KEY_DELETE, currency) + assertEquals(5000L, viewModel.uiState.value.sats) + assertNull(viewModel.uiState.value.errorKey) + } + + @Test + fun `no max exceeded effect emitted on delete above cap`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + "50000".forEach { viewModel.handleNumberPadInput(it.toString(), currency) } + viewModel.setMaxAmount(30000) + + var effectReceived = false + val job = backgroundScope.launch(testDispatcher) { + viewModel.effect.collect { + if (it is AmountInputEffect.MaxExceeded) effectReceived = true + } + } + + // Deleting an over-cap amount must not emit MaxExceeded + viewModel.handleNumberPadInput(KEY_DELETE, currency) + assertEquals(5000L, viewModel.uiState.value.sats) + assertFalse(effectReceived) + + job.cancel() + } + + @Test + fun `setMaxAmount with zero keeps input usable`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + viewModel.setMaxAmount(0) + + var effectReceived = false + val job = backgroundScope.launch(testDispatcher) { + viewModel.effect.collect { + if (it is AmountInputEffect.MaxExceeded) effectReceived = true + } + } + + // A zero cap means no cap - input is accepted, no MaxExceeded effect + "500".forEach { viewModel.handleNumberPadInput(it.toString(), currency) } + assertEquals(500L, viewModel.uiState.value.sats) + assertNull(viewModel.uiState.value.errorKey) + assertFalse(effectReceived) + + job.cancel() + } + + @Test + fun `setMaxAmount with negative value keeps input usable`() = test { + val currency = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.MODERN) + + viewModel.setMaxAmount(-1) + + "500".forEach { viewModel.handleNumberPadInput(it.toString(), currency) } + assertEquals(500L, viewModel.uiState.value.sats) + assertNull(viewModel.uiState.value.errorKey) + } + @Test fun `classic conversion calculations are accurate`() = test { val btcClassic = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC) diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt new file mode 100644 index 0000000000..2f45eefa3e --- /dev/null +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -0,0 +1,149 @@ +package to.bitkit.viewmodels + +import android.content.Context +import com.synonym.bitkitcore.ChannelLiquidityOptions +import com.synonym.bitkitcore.IBtEstimateFeeResponse2 +import com.synonym.bitkitcore.IBtInfo +import com.synonym.bitkitcore.IBtInfoOptions +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Before +import org.junit.Test +import org.lightningdevkit.ldknode.NodeStatus +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.models.BalanceState +import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.BlocktankState +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.LightningState +import to.bitkit.repositories.TransferRepo +import to.bitkit.repositories.WalletRepo +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) +class TransferViewModelTest : BaseUnitTest() { + private lateinit var sut: TransferViewModel + + private val context = mock() + private val lightningRepo = mock() + private val blocktankRepo = mock() + private val walletRepo = mock() + private val settingsStore = mock() + private val cacheStore = mock() + private val transferRepo = mock() + private val clock = mock() + + private val balanceState = MutableStateFlow(BalanceState()) + private val blocktankState = MutableStateFlow(BlocktankState()) + private val feeResponse = mock() + + @Before + fun setUp() { + whenever(feeResponse.feeSat).thenReturn(LSP_FEE) + whenever(feeResponse.networkFeeSat).thenReturn(NETWORK_FEE) + whenever(feeResponse.serviceFeeSat).thenReturn(SERVICE_FEE) + whenever(context.getString(any())).thenReturn("") + whenever(settingsStore.data).thenReturn(MutableStateFlow(SettingsData())) + val nodeStatus = mock() + whenever(nodeStatus.isRunning).thenReturn(true) + whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(nodeStatus = nodeStatus))) + whenever(walletRepo.balanceState).thenReturn(balanceState) + whenever(blocktankRepo.blocktankState).thenReturn(blocktankState) + + sut = TransferViewModel( + context = context, + lightningRepo = lightningRepo, + blocktankRepo = blocktankRepo, + walletRepo = walletRepo, + settingsStore = settingsStore, + cacheStore = cacheStore, + transferRepo = transferRepo, + clock = clock, + ) + } + + @Test + fun `updateLimits caps spending max at LSP max client balance when on-chain balance exceeds it`() = test { + balanceState.value = BalanceState(maxSendOnchainSats = ON_CHAIN_BALANCE) + blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) + // The LSP reports no room for receiving liquidity (maxLspBalanceSat = 0) because the + // client balance saturates the channel — the regression this guards against. + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) + + sut.updateLimits() + advanceUntilIdle() + + val state = sut.spendingUiState.value + assertEquals(OPTION_MAX_CLIENT_BALANCE.toLong(), state.maxAllowedToSend) + assertEquals(OPTION_MAX_CLIENT_BALANCE.toLong(), state.balanceAfterFee) + + // The order fee must be estimated against the clamped client balance, not the full balance. + verify(blocktankRepo).estimateOrderFee(eq(LSP_MAX_CLIENT_BALANCE), any(), any()) + } + + @Test + fun `updateLimits uses the full balance when LSP info is unavailable`() = test { + balanceState.value = BalanceState(maxSendOnchainSats = ON_CHAIN_BALANCE) + blocktankState.value = BlocktankState(info = null) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) + + sut.updateLimits() + advanceUntilIdle() + + assertEquals(OPTION_MAX_CLIENT_BALANCE.toLong(), sut.spendingUiState.value.maxAllowedToSend) + // Without an LSP cap the order fee is estimated against the balance after the LSP fee. + verify(blocktankRepo).estimateOrderFee(eq(ON_CHAIN_BALANCE - LSP_FEE), any(), any()) + } + + @Test + fun `updateLimits sets max to zero when LSP reports zero client balance`() = test { + balanceState.value = BalanceState(maxSendOnchainSats = ON_CHAIN_BALANCE) + blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = 0uL))) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) + + sut.updateLimits() + advanceUntilIdle() + + assertEquals(0L, sut.spendingUiState.value.maxAllowedToSend) + } + + private fun liquidityOptions(maxClientBalanceSat: ULong) = ChannelLiquidityOptions( + defaultLspBalanceSat = LSP_BALANCE, + minLspBalanceSat = LSP_BALANCE, + maxLspBalanceSat = 0uL, + maxClientBalanceSat = maxClientBalanceSat, + ) + + private fun btInfo(lspMaxClientBalance: ULong): IBtInfo { + val options = mock() + whenever(options.maxClientBalanceSat).thenReturn(lspMaxClientBalance) + return mock().also { whenever(it.options).thenReturn(options) } + } + + private companion object { + const val ON_CHAIN_BALANCE = 10_000_000uL + const val LSP_MAX_CLIENT_BALANCE = 1_766_193uL + const val OPTION_MAX_CLIENT_BALANCE = 1_687_598uL + const val LSP_BALANCE = 252_368uL + const val NETWORK_FEE = 2_112uL + const val SERVICE_FEE = 286uL + const val LSP_FEE = 2_398uL // NETWORK_FEE + SERVICE_FEE + } +} diff --git a/changelog.d/next/908.fixed.md b/changelog.d/next/908.fixed.md new file mode 100644 index 0000000000..247f60d991 --- /dev/null +++ b/changelog.d/next/908.fixed.md @@ -0,0 +1 @@ +The amount number pad now caps entry at the available maximum on the send, LNURL, transfer, and channel-funding screens, stays usable at a zero balance, and lets you delete down from an over-cap amount. diff --git a/journeys/amount-limits/README.md b/journeys/amount-limits/README.md new file mode 100644 index 0000000000..e89c47fd6b --- /dev/null +++ b/journeys/amount-limits/README.md @@ -0,0 +1,55 @@ +# Amount-limit journeys + +These journeys exercise the "block numberpad input exceeding the max/available amount" +behaviour added on `fix/block-input-over-max`. The same `AmountInputViewModel.setMaxAmount` + +`MaxExceeded` effect path backs all four amount-entry screens (Send, Transfer→Spending, +Receiving capacity, External node). + +## What the feature does +- Typing a digit that would push the amount **over the cap is rejected** — the display stays at + the largest value still within the cap (e.g. tapping `9` repeatedly stops at `9 999` when the + cap is `98 064`, because `99 999` would exceed it). +- A **short (1.5s) WARNING toast** is emitted on the first rejected keypress. +- **Delete is always allowed**, even when sitting at the cap. + +## Mandatory setup (learned the hard way) +1. **Fund a real, positive available balance first.** With `0` available, `setMaxAmount(0)` + falls back to the global `MAX_AMOUNT` cap (the code only applies the limit when `amount > 0`), + so nothing gets blocked and the journeys silently pass for the wrong reason. + - Get an on-chain (Savings) address from Receive → Show Details. + - Fund + mine via the `lsp` skill: + `./lsp POST /regtest/chain/deposit '{"address":"","amountSat":100000}'` + then `./lsp POST /regtest/chain/mine '{"count":3}'` and wait for the balance to sync. +2. **Transfer/Spending/Receiving-capacity flows need the node connected to the LSP** so a real + max can be quoted. On the Spending amount screen the max starts at `0` behind a spinner — + **wait for it to populate** before typing. +3. **External-node flow needs a reachable LN peer.** The staging LSP node works as the peer: + id `028a8910b0048630d4eb17af25668cdd7ea6f2d8ae20956e7a06e2ae46ebcb69fc`, + host `34.65.86.104`, port `9400` (from `./lsp GET /info`). + +## Gotchas +- **The cap can be lower than the visible "Available".** Fee/channel reserves mean e.g. Available + `99 890` but the spending max is `98 064`. Assert "does not exceed the **stated maximum**", + not "Available". +- **Do not assert Continue is disabled when over the max.** Because the input is *capped* (never + left in an over-max state), the capped value is valid and Continue stays **enabled**. +- The toast is WARNING type and lasts ~1.5s — verify it immediately after the over-max keypress. +- `adb input text` can drop characters in dotted strings (host IPs) — type digit groups + dots + separately, then verify. +- Full-res screenshots can exceed image limits; prefer `android layout` JSON and tap elements by + their test tag (`N9`, `NRemove`, etc.). + +## Test tags used +- NumberPad keys: digits `N0`–`N9`, triple-zero `N000`, decimal `NDecimal`, delete `NRemove`. +- Send: screen `send_amount_screen`, field `SendNumberField`, available `available_balance`, + max `SendAmountMax`, continue `ContinueAmount`; recipient `RecipientManual` / `RecipientInput` + / `AddressContinue`; home Send button `Send`. +- Transfer→Spending: screen `SpendingAmount`, field `SpendingAmountNumberField`, + available `SpendingAmountAvailable`, 25% `SpendingAmountQuarter`, max `SpendingAmountMax`, + continue `SpendingAmountContinue`. +- Receiving capacity: screen `SpendingAdvanced`, field `SpendingAdvancedNumberField`, + min/default/max `SpendingAdvancedMin`/`SpendingAdvancedDefault`/`SpendingAdvancedMax`, + continue `SpendingAdvancedContinue`. +- External: funding `FundManual`; connection `NodeIdInput`/`HostInput`/`PortInput`/`ExternalContinue`; + amount screen `ExternalAmount`, field `ExternalAmountNumberField`, 25% `ExternalAmountQuarter`, + max `ExternalAmountMax`, continue `ExternalAmountContinue`. diff --git a/journeys/amount-limits/external-amount-over-max.xml b/journeys/amount-limits/external-amount-over-max.xml new file mode 100644 index 0000000000..b633dbdced --- /dev/null +++ b/journeys/amount-limits/external-amount-over-max.xml @@ -0,0 +1,29 @@ + + + Verifies the external node funding amount number pad blocks input exceeding the maximum, shows the + "Spending Balance Maximum" warning toast, and still allows deleting digits while at the cap. + + Precondition: onboarded dev wallet with a POSITIVE on-chain Savings balance and a reachable + external Lightning node to peer with — the staging LSP node works (id + 028a8910b0048630d4eb17af25668cdd7ea6f2d8ae20956e7a06e2ae46ebcb69fc, host 34.65.86.104, port 9400; + see `./lsp GET /info`). Start on the wallet home screen. + + Do NOT assert that Continue is disabled at the over-max input: the input is capped (never left in + an over-max state), so the capped value is valid and Continue stays enabled. + + + Open Settings, then the "Advanced" tab, then "Lightning Connections" + Tap "Get Started" to open the funding options screen + Tap "Manual Setup" (testTag "FundManual") + Type the node id into the node id field (testTag "NodeIdInput") + Type the host into the host field (testTag "HostInput") + Type the port into the port field (testTag "PortInput") + Tap Continue (testTag "ExternalContinue") and wait for the peer connection to succeed + Verify the external node amount screen (testTag "ExternalAmount") is visible and an available amount is displayed + Tap the "9" key (testTag "N9") nine times to enter an amount far larger than the maximum allowed + Verify a "Spending Balance Maximum" warning toast appears + Verify the amount in the input field (testTag "ExternalAmountNumberField") does not exceed the displayed maximum + Tap the delete key (testTag "NRemove") once + Verify the amount in the input field (testTag "ExternalAmountNumberField") decreased after the delete + + diff --git a/journeys/amount-limits/send-amount-over-balance.xml b/journeys/amount-limits/send-amount-over-balance.xml new file mode 100644 index 0000000000..9f879c992f --- /dev/null +++ b/journeys/amount-limits/send-amount-over-balance.xml @@ -0,0 +1,24 @@ + + + Verifies the Send amount number pad blocks input exceeding the available balance, shows an + "Insufficient balance" warning toast, and still allows deleting digits while at the cap. + + Precondition: onboarded dev wallet with a known POSITIVE on-chain (Savings) balance — without a + positive balance the max falls back to the global cap and nothing is blocked. Fund ~100 000 sats + via the lsp regtest deposit + mine and wait for it to sync (see README.md). Have a valid regtest + bitcoin address ready to type. Start on the wallet home screen. + + + Tap the Send button (testTag "Send") + If a camera permission dialog appears, dismiss it by choosing "Don't allow" + Tap "Enter Manually" (testTag "RecipientManual") + Type a valid regtest bitcoin address into the recipient field (testTag "RecipientInput") + Tap Continue (testTag "AddressContinue") + Verify the Send amount screen (testTag "send_amount_screen") is visible and the available balance (testTag "available_balance") is displayed + Tap the "9" key (testTag "N9") nine times to enter an amount far larger than the available balance + Verify an "Insufficient balance" warning toast appears + Verify the amount in the input field (testTag "SendNumberField") does not exceed the available balance + Tap the delete key (testTag "NRemove") once + Verify the amount in the input field (testTag "SendNumberField") decreased after the delete + + diff --git a/journeys/amount-limits/transfer-spending-advanced-over-max.xml b/journeys/amount-limits/transfer-spending-advanced-over-max.xml new file mode 100644 index 0000000000..e0b1557fbe --- /dev/null +++ b/journeys/amount-limits/transfer-spending-advanced-over-max.xml @@ -0,0 +1,26 @@ + + + Verifies the receiving capacity (advanced) number pad blocks input exceeding the maximum LSP + balance, shows the "Receiving Capacity Maximum" warning toast, and still allows deleting digits + while at the cap. + + Precondition: onboarded dev wallet with a POSITIVE on-chain Savings balance and a running node + connected to the LSP. Start on the wallet home screen. The advanced screen is reached by first + setting a valid spending amount and continuing to the confirm screen. + + + Tap the Spending balance card on the home screen + Tap "Transfer from Savings" + If a transfer intro screen appears, tap "Get Started" + Wait until the spending amount screen (testTag "SpendingAmount") has loaded a positive maximum (testTag "SpendingAmountAvailable") + Tap "25%" (testTag "SpendingAmountQuarter") to set a valid amount within range + Tap Continue (testTag "SpendingAmountContinue") + On the confirm screen, tap "Advanced" + Verify the receiving capacity screen (testTag "SpendingAdvanced") is visible + Tap the "9" key (testTag "N9") nine times to enter a capacity far larger than the maximum allowed + Verify a "Receiving Capacity Maximum" warning toast appears + Verify the amount in the input field (testTag "SpendingAdvancedNumberField") does not exceed the maximum receiving capacity + Tap the delete key (testTag "NRemove") once + Verify the amount in the input field (testTag "SpendingAdvancedNumberField") decreased after the delete + + diff --git a/journeys/amount-limits/transfer-spending-over-max.xml b/journeys/amount-limits/transfer-spending-over-max.xml new file mode 100644 index 0000000000..3e1a3a9544 --- /dev/null +++ b/journeys/amount-limits/transfer-spending-over-max.xml @@ -0,0 +1,24 @@ + + + Verifies the "Transfer to Spending" amount number pad blocks input exceeding the maximum allowed + transfer amount, shows the "Spending Balance Maximum" warning toast, and still allows deleting + digits while at the cap. + + Precondition: onboarded dev wallet with a POSITIVE on-chain Savings balance and a running node + connected to the LSP (so a real max can be quoted). The max starts at 0 behind a spinner — wait + for it to populate. Note the cap may be lower than the visible "Available" due to fees. Start on + the wallet home screen. + + + Tap the Spending balance card on the home screen + Tap "Transfer from Savings" + If a transfer intro screen appears, tap "Get Started" + Verify the spending amount screen (testTag "SpendingAmount") is visible + Wait until the available/maximum amount (testTag "SpendingAmountAvailable") finishes loading and shows a positive value + Tap the "9" key (testTag "N9") nine times to enter an amount far larger than the maximum allowed + Verify a "Spending Balance Maximum" warning toast appears + Verify the amount in the input field (testTag "SpendingAmountNumberField") does not exceed the stated maximum + Tap the delete key (testTag "NRemove") once + Verify the amount in the input field (testTag "SpendingAmountNumberField") decreased after the delete + +