From 4a034aa9e85179ece4fa9acdb6c0dffaad8ed59a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 26 Mar 2026 18:45:37 +0100 Subject: [PATCH 01/20] 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 02/20] 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 From dbd19356498f3f25262da20a3cc4f37cdcc97ea1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 7 May 2026 13:23:35 +0200 Subject: [PATCH 03/20] test: cover fiat max amount input --- .../viewmodels/AmountInputViewModelTest.kt | 16 ++++++++++++++++ changelog.d/next/908.fixed.md | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt index c831829153..7da964630d 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt @@ -944,6 +944,22 @@ class AmountInputViewModelTest : BaseUnitTest() { 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) diff --git a/changelog.d/next/908.fixed.md b/changelog.d/next/908.fixed.md index 26d638301c..a546d6e504 100644 --- a/changelog.d/next/908.fixed.md +++ b/changelog.d/next/908.fixed.md @@ -1 +1 @@ -Block numberpad input above max amount on Send, Transfer to Spending, and Receiving Capacity screens, with short toast explaining the limit +Block number pad input above max available amount. From dba62ffedb6a90abc63c90c7a336ab5dcee330b0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 8 Jun 2026 13:49:24 -0300 Subject: [PATCH 04/20] fix: cap amount pad and allow delete over cap --- .../transfer/external/ExternalAmountScreen.kt | 15 +++- .../external/ExternalNodeViewModel.kt | 25 +++--- .../screens/wallets/send/SendAmountScreen.kt | 9 ++- .../bitkit/viewmodels/AmountInputViewModel.kt | 6 +- .../viewmodels/AmountInputViewModelTest.kt | 77 +++++++++++++++++++ changelog.d/next/908.fixed.md | 2 +- 6 files changed, 114 insertions(+), 20 deletions(-) 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..679aa513f2 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(amountInputViewModel) { + 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 87559e6190..ea1881bda8 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 @@ -217,8 +217,13 @@ private fun SendAmountNodeRunning( } } - LaunchedEffect(availableAmount) { - amountInputViewModel.setMaxAmount(availableAmount) + val maxAllowed = when (val lnurl = uiState.lnurl) { + is LnurlParams.LnurlPay -> minOf(lnurl.data.maxSendableSat().toLong(), availableAmount) + else -> availableAmount + } + + LaunchedEffect(maxAllowed) { + amountInputViewModel.setMaxAmount(maxAllowed) } Column( diff --git a/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt index 6e17616a8e..7a4e0b24f1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AmountInputViewModel.kt @@ -62,7 +62,7 @@ class AmountInputViewModel @Inject constructor( private var rawInputText: String = "" fun setMaxAmount(amount: Long) { - maxAmount = amount.coerceIn(0, MAX_AMOUNT) + maxAmount = if (amount > 0) amount.coerceAtMost(MAX_AMOUNT) else MAX_AMOUNT } fun handleNumberPadInput( @@ -85,7 +85,7 @@ class AmountInputViewModel @Inject constructor( if (primaryDisplay == PrimaryDisplay.BITCOIN && isModern) { val newAmount = convertToSats(newText, primaryDisplay, isModern = true) - if (newAmount <= maxAmount) { + if (key == KEY_DELETE || newAmount <= maxAmount) { rawInputText = newText _uiState.update { it.copy( @@ -102,7 +102,7 @@ class AmountInputViewModel @Inject constructor( // For decimal input, check limits before updating state if (newText.isNotEmpty()) { val newAmount = convertToSats(newText, primaryDisplay, isModern) - if (newAmount <= maxAmount) { + if (key == KEY_DELETE || newAmount <= maxAmount) { // Update both raw input and display text rawInputText = newText _uiState.update { diff --git a/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt index 7da964630d..32edff9dd7 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt @@ -1043,6 +1043,83 @@ class AmountInputViewModelTest : BaseUnitTest() { 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/changelog.d/next/908.fixed.md b/changelog.d/next/908.fixed.md index a546d6e504..247f60d991 100644 --- a/changelog.d/next/908.fixed.md +++ b/changelog.d/next/908.fixed.md @@ -1 +1 @@ -Block number pad input above max available amount. +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. From 9f78b11d1d248bb9f1f87acfe8df02437e98b213 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 8 Jun 2026 13:59:01 -0300 Subject: [PATCH 05/20] fix: use Unit as LaunchEffect key instead of a viewmodel instance --- .../bitkit/ui/screens/transfer/external/ExternalAmountScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 679aa513f2..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 @@ -68,7 +68,7 @@ fun ExternalAmountScreen( amountInputViewModel.setMaxAmount(uiState.amount.max) } - LaunchedEffect(amountInputViewModel) { + LaunchedEffect(Unit) { amountInputViewModel.effect.collect { when (it) { AmountInputEffect.MaxExceeded -> viewModel.onMaxExceeded() From 7ea4be7aec0fe93b1cdf559605b5b0df433bc6a0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 8 Jun 2026 14:42:15 -0300 Subject: [PATCH 06/20] fix: use Unit as LaunchEffect key instead of a viewmodel instance --- .../to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt | 2 +- .../java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 ebde6710aa..fc708cb050 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 @@ -107,7 +107,7 @@ fun SpendingAdvancedScreen( } } - LaunchedEffect(amountInputViewModel) { + LaunchedEffect(Unit) { amountInputViewModel.effect.collect { when (it) { AmountInputEffect.MaxExceeded -> app.toast( 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 f5a9770eee..e577924927 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 @@ -86,7 +86,7 @@ fun SpendingAmountScreen( } } - LaunchedEffect(amountInputViewModel) { + LaunchedEffect(Unit) { amountInputViewModel.effect.collect { when (it) { AmountInputEffect.MaxExceeded -> toast( From bc37389dc52e906482f95ea38b9fa7afc115741a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 8 Jun 2026 14:44:09 -0300 Subject: [PATCH 07/20] fix: use Unit as LaunchEffect key instead of a viewmodel instance --- .../java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ea1881bda8..ec1ee2887e 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 @@ -91,7 +91,7 @@ fun SendAmountScreen( currentOnEvent(SendEvent.AmountChange(amountInputUiState.sats.toULong())) } - LaunchedEffect(amountInputViewModel) { + LaunchedEffect(Unit) { amountInputViewModel.effect.collect { when (it) { AmountInputEffect.MaxExceeded -> app?.toast( From 046b6fb151ee518446ad6dc81e9083778cb27e98 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 8 Jun 2026 14:49:47 -0300 Subject: [PATCH 08/20] fix: apply modern bitcoin formatting --- .../to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt | 3 ++- .../to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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 fc708cb050..6b94792769 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 @@ -28,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 @@ -114,7 +115,7 @@ fun SpendingAdvancedScreen( 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}"), + .replace("{amount}", transferValues.maxLspBalance.formatToModernDisplay()), visibilityTime = Toast.VISIBILITY_TIME_SHORT, ) } 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 e577924927..bf3b1cafeb 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 @@ -26,6 +26,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 @@ -92,7 +93,7 @@ fun SpendingAmountScreen( 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}"), + .replace("{amount}", uiState.maxAllowedToSend.formatToModernDisplay()), ) } } @@ -112,7 +113,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) From 04df89d35ff04ceab105169bc946eb18d0aad5e0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 9 Jun 2026 07:49:36 -0300 Subject: [PATCH 09/20] chore: add docker parameter to just run command to run with bitkit-docker --- Justfile | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/Justfile b/Justfile index a0b861ea27..f9c7d1455e 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,18 @@ 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 + 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 \ From 52a32aa622c6e32e978039dcd99b3eb84da4afec Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 9 Jun 2026 07:57:31 -0300 Subject: [PATCH 10/20] chore: forward lnurl-server port in just run docker --- Justfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Justfile b/Justfile index f9c7d1455e..0b42d0dfd7 100644 --- a/Justfile +++ b/Justfile @@ -103,6 +103,7 @@ run mode="": 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 From 3e2e93989f5e4167d8f348b0ab1331ee854bfc73 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 9 Jun 2026 08:32:28 -0300 Subject: [PATCH 11/20] fix: set different message for max invoice value --- .../screens/wallets/send/SendAmountScreen.kt | 25 +++++++++++++++++-- app/src/main/res/values/strings.xml | 2 ++ 2 files changed, 25 insertions(+), 2 deletions(-) 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 ec1ee2887e..fb23c9829a 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 @@ -74,6 +74,7 @@ fun SendAmountScreen( onBack: () -> Unit, onEvent: (SendEvent) -> Unit, currencies: CurrencyState = LocalCurrencies.current, + balances: BalanceState = LocalBalances.current, amountInputViewModel: AmountInputViewModel = hiltViewModel(), ) { val app = appViewModel @@ -81,6 +82,14 @@ fun SendAmountScreen( val amountInputUiState: AmountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() val currentOnEvent by rememberUpdatedState(onEvent) + val lnurlPayMaxExceeded = run { + val lnurl = uiState.lnurl + lnurl is LnurlParams.LnurlPay && + lnurl.data.maxSendableSat().toLong() < + (balances.maxSendLightningSats.safe() - uiState.estimatedRoutingFee.safe()).toLong() + } + val currentLnurlPayMaxExceeded by rememberUpdatedState(lnurlPayMaxExceeded) + LaunchedEffect(Unit) { if (uiState.amount > 0u) { amountInputViewModel.setSats(uiState.amount.toLong(), currencies) @@ -96,8 +105,20 @@ fun SendAmountScreen( 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), + title = context.getString( + if (currentLnurlPayMaxExceeded) { + R.string.wallet__lnurl_pay__error_max__title + } else { + R.string.wallet__send_amount_exceeded__title + } + ), + description = context.getString( + if (currentLnurlPayMaxExceeded) { + R.string.wallet__lnurl_pay__error_max__description + } else { + R.string.wallet__send_amount_exceeded__description + } + ), visibilityTime = Toast.VISIBILITY_TIME_SHORT, ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3f2aae40bd..dbccb2cf95 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1063,6 +1063,8 @@ 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 From 8a79faa74b1718e0f51ad10aef6d4540615a536a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 9 Jun 2026 08:50:40 -0300 Subject: [PATCH 12/20] fix: lnurl withdraw max exceeded message --- .../screens/wallets/send/SendAmountScreen.kt | 41 +++++++++---------- app/src/main/res/values/strings.xml | 2 + 2 files changed, 22 insertions(+), 21 deletions(-) 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 fb23c9829a..81afe63af7 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 @@ -82,13 +82,21 @@ fun SendAmountScreen( val amountInputUiState: AmountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() val currentOnEvent by rememberUpdatedState(onEvent) - val lnurlPayMaxExceeded = run { + val maxExceededMessage = run { val lnurl = uiState.lnurl - lnurl is LnurlParams.LnurlPay && + 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 currentLnurlPayMaxExceeded by rememberUpdatedState(lnurlPayMaxExceeded) + val currentMaxExceededMessage by rememberUpdatedState(maxExceededMessage) LaunchedEffect(Unit) { if (uiState.amount > 0u) { @@ -103,24 +111,15 @@ fun SendAmountScreen( LaunchedEffect(Unit) { amountInputViewModel.effect.collect { when (it) { - AmountInputEffect.MaxExceeded -> app?.toast( - type = Toast.ToastType.WARNING, - title = context.getString( - if (currentLnurlPayMaxExceeded) { - R.string.wallet__lnurl_pay__error_max__title - } else { - R.string.wallet__send_amount_exceeded__title - } - ), - description = context.getString( - if (currentLnurlPayMaxExceeded) { - R.string.wallet__lnurl_pay__error_max__description - } else { - R.string.wallet__send_amount_exceeded__description - } - ), - visibilityTime = Toast.VISIBILITY_TIME_SHORT, - ) + 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, + ) + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dbccb2cf95..b5d018958e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1070,6 +1070,8 @@ 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 From 548e6e803bdc1576268ca2f2f2f6b8f48ca97ab9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 9 Jun 2026 09:11:40 -0300 Subject: [PATCH 13/20] fix: use rememberUpdatedState for prevent stale amount --- .../to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt | 3 ++- .../to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) 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 6b94792769..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 @@ -74,6 +74,7 @@ fun SpendingAdvancedScreen( 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) @@ -115,7 +116,7 @@ fun SpendingAdvancedScreen( 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.formatToModernDisplay()), + .replace("{amount}", currentMaxLspBalance.formatToModernDisplay()), visibilityTime = Toast.VISIBILITY_TIME_SHORT, ) } 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 bf3b1cafeb..8a6af80b33 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 @@ -72,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() @@ -93,7 +95,7 @@ fun SpendingAmountScreen( 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.formatToModernDisplay()), + .replace("{amount}", currentMaxAllowedToSend.formatToModernDisplay()), ) } } From b1f1b877ad50134981e97d4d99f5b3836ce45731 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 9 Jun 2026 09:24:30 -0300 Subject: [PATCH 14/20] fix: use same value for maxAllowedToSend and balanceAfterFee --- .../java/to/bitkit/viewmodels/TransferViewModel.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 4460866030..4359dc7e7b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -391,15 +391,16 @@ class TransferViewModel @Inject constructor( 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 { From 503470d08059f0511868aa53e81f92e7d27e2e3e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 9 Jun 2026 09:24:48 -0300 Subject: [PATCH 15/20] chore: add journeys --- journeys/amount-limits/README.md | 55 +++++++++++++++++++ .../external-amount-over-max.xml | 29 ++++++++++ .../send-amount-over-balance.xml | 24 ++++++++ .../transfer-spending-advanced-over-max.xml | 26 +++++++++ .../transfer-spending-over-max.xml | 24 ++++++++ 5 files changed, 158 insertions(+) create mode 100644 journeys/amount-limits/README.md create mode 100644 journeys/amount-limits/external-amount-over-max.xml create mode 100644 journeys/amount-limits/send-amount-over-balance.xml create mode 100644 journeys/amount-limits/transfer-spending-advanced-over-max.xml create mode 100644 journeys/amount-limits/transfer-spending-over-max.xml 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 + + From 5cd03c4491255f7a7a18db5dd8fb4f5dd257eea8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 9 Jun 2026 09:46:21 -0300 Subject: [PATCH 16/20] fix: disable NumberPad.kt while loading --- app/src/main/java/to/bitkit/ui/components/NumberPad.kt | 3 ++- .../java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) 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..20690034f5 100644 --- a/app/src/main/java/to/bitkit/ui/components/NumberPad.kt +++ b/app/src/main/java/to/bitkit/ui/components/NumberPad.kt @@ -170,6 +170,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,7 +178,7 @@ fun NumberPad( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() NumberPad( - onPress = { key -> viewModel.handleNumberPadInput(key, currencies) }, + onPress = { key -> if (enabled) viewModel.handleNumberPadInput(key, currencies) }, modifier = modifier, type = type, availableHeight = availableHeight, 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 8a6af80b33..763e4c3585 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 @@ -18,6 +18,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -264,6 +265,8 @@ private fun SpendingAmountNodeRunning( NumberPad( viewModel = amountInputViewModel, currencies = currencies, + enabled = !uiState.isLoading, + modifier = Modifier.alpha(if (uiState.isLoading) 0.5f else 1f) ) PrimaryButton( From c377dfa20aa0798fb26dde4bcf8360f046a881ac Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 9 Jun 2026 09:55:01 -0300 Subject: [PATCH 17/20] refactor: move alpha to inside NumberPad --- app/src/main/java/to/bitkit/ui/components/NumberPad.kt | 3 ++- .../java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) 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 20690034f5..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 @@ -179,7 +180,7 @@ fun NumberPad( val uiState by viewModel.uiState.collectAsStateWithLifecycle() NumberPad( onPress = { key -> if (enabled) viewModel.handleNumberPadInput(key, currencies) }, - modifier = modifier, + 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/SpendingAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt index 763e4c3585..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 @@ -18,7 +18,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -266,7 +265,6 @@ private fun SpendingAmountNodeRunning( viewModel = amountInputViewModel, currencies = currencies, enabled = !uiState.isLoading, - modifier = Modifier.alpha(if (uiState.isLoading) 0.5f else 1f) ) PrimaryButton( From 7f1b088399d87b1e74095d82e6742f04bd53ba28 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 9 Jun 2026 11:12:44 -0300 Subject: [PATCH 18/20] fix: cap balanceAfterLspFee to maxClientBalanceSat --- .../to/bitkit/viewmodels/TransferViewModel.kt | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 4359dc7e7b..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,7 +395,7 @@ class TransferViewModel @Inject constructor( val receivingAmount = maxOf(liquidity.defaultLspBalanceSat, liquidity.minLspBalanceSat) blocktankRepo.estimateOrderFee( - spendingBalanceSats = balanceAfterLspFee, + spendingBalanceSats = cappedClientBalance, receivingBalanceSats = receivingAmount, ).onSuccess { estimate -> maxLspFee = estimate.feeSat From 8455f10d957d647453b40aece0d94ca69dbc87cb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 9 Jun 2026 11:24:15 -0300 Subject: [PATCH 19/20] test: lsp calc regression tests --- .../viewmodels/TransferViewModelTest.kt | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt 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 + } +} From 631c98eec437597c7db721b80b3576de12a8520e Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 10 Jun 2026 09:38:41 +0200 Subject: [PATCH 20/20] test: SendAmountExceededToast --- .../java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt | 1 + 1 file changed, 1 insertion(+) 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 81afe63af7..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 @@ -118,6 +118,7 @@ fun SendAmountScreen( title = context.getString(titleRes), description = context.getString(descriptionRes), visibilityTime = Toast.VISIBILITY_TIME_SHORT, + testTag = "SendAmountExceededToast", ) } }