From 4a034aa9e85179ece4fa9acdb6c0dffaad8ed59a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 26 Mar 2026 18:45:37 +0100 Subject: [PATCH 1/2] feat: block numberpad input exceeding available balance Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/main/java/to/bitkit/models/Toast.kt | 1 + .../transfer/SpendingAdvancedScreen.kt | 21 ++++ .../screens/transfer/SpendingAmountScreen.kt | 17 +++ .../screens/wallets/send/SendAmountScreen.kt | 18 +++ .../bitkit/viewmodels/AmountInputViewModel.kt | 30 ++++- app/src/main/res/values/strings.xml | 4 + .../viewmodels/AmountInputViewModelTest.kt | 108 ++++++++++++++++++ 7 files changed, 195 insertions(+), 4 deletions(-) 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/screens/transfer/SpendingAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt index 7e92fad64e..ebde6710aa 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 @@ -46,6 +47,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,6 +66,7 @@ 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() @@ -79,6 +82,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 +107,20 @@ fun SpendingAdvancedScreen( } } + LaunchedEffect(amountInputViewModel) { + 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}", "${transferValues.maxLspBalance}"), + 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 d313421c21..bdbabd18ad 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 @@ -41,6 +41,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 @@ -78,6 +79,18 @@ fun SpendingAmountScreen( } } + LaunchedEffect(amountInputViewModel) { + 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}", "${uiState.maxAllowedToSend}"), + ) + } + } + } + Content( isNodeRunning = isNodeRunning, uiState = uiState, @@ -155,6 +168,10 @@ private fun SpendingAmountNodeRunning( onClickMaxAmount: () -> Unit, onConfirmAmount: () -> Unit, ) { + LaunchedEffect(uiState.maxAllowedToSend) { + amountInputViewModel.setMaxAmount(uiState.maxAllowedToSend) + } + Column( modifier = Modifier .padding(horizontal = 16.dp) 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 292aeaca38..440a53f0cb 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 @@ -57,6 +57,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 @@ -91,6 +92,19 @@ fun SendAmountScreen( currentOnEvent(SendEvent.AmountChange(amountInputUiState.sats.toULong())) } + LaunchedEffect(amountInputViewModel) { + amountInputViewModel.effect.collect { + when (it) { + AmountInputEffect.MaxExceeded -> app?.toast( + type = Toast.ToastType.WARNING, + title = context.getString(R.string.wallet__send_amount_exceeded__title), + description = context.getString(R.string.wallet__send_amount_exceeded__description), + visibilityTime = Toast.VISIBILITY_TIME_SHORT, + ) + } + } + } + LaunchedEffect(uiState.decodedInvoice, uiState.payMethod) { if (uiState.payMethod == SendMethod.LIGHTNING && uiState.decodedInvoice != null) { currentOnEvent(SendEvent.EstimateMaxRoutingFee) @@ -203,6 +217,10 @@ private fun SendAmountNodeRunning( } } + LaunchedEffect(availableAmount) { + amountInputViewModel.setMaxAmount(availableAmount) + } + 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..6e17616a8e 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 = amount.coerceIn(0, 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 (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 (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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 20c91cd2bc..0725e48049 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -267,6 +267,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}. @@ -1056,6 +1058,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..c831829153 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,112 @@ 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 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 `classic conversion calculations are accurate`() = test { val btcClassic = mockCurrency(PrimaryDisplay.BITCOIN, BitcoinDisplayUnit.CLASSIC) From e31490453cbb0d3d9de93d1f30f8ed2855ee708d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 23 Apr 2026 23:50:28 +0200 Subject: [PATCH 2/2] chore: changelog entry Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 299beaf0f3..6abdf148b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Block numberpad input above max amount on Send, Transfer to Spending, and Receiving Capacity screens, with short toast explaining the limit #908 + ## [2.2.0] - 2026-04-07 ### Fixed