From 3c92b5810e26c197b5314cd2018de4d3760d625c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 25 Aug 2026 08:14:22 -0300 Subject: [PATCH 01/12] fix: improve advanced funding budget logic --- .../transfer/SpendingAdvancedScreen.kt | 10 ++- .../to/bitkit/viewmodels/TransferViewModel.kt | 53 ++++++++++++++ app/src/main/res/values/strings.xml | 2 + .../viewmodels/TransferViewModelTest.kt | 71 +++++++++++++++++++ 4 files changed, 135 insertions(+), 1 deletion(-) 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 d91a05306b..13d029a503 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 @@ -79,6 +79,7 @@ fun SpendingAdvancedScreen( LaunchedEffect(order.clientBalanceSat) { viewModel.updateTransferValues(order.clientBalanceSat) + viewModel.updateAdvancedFundingBudget() } LaunchedEffect(amountUiState.sats) { @@ -129,10 +130,17 @@ fun SpendingAdvancedScreen( } } - val isValid = transferValues.let { + val isInRange = transferValues.let { val amount = amountUiState.sats.toULong() amount > 0u && it.maxLspBalance > 0u && amount in it.minLspBalance..it.maxLspBalance } + // Max sets the capacity from the LSP's liquidity limit, which ignores what the wallet can pay + // for. Until the quote lands the confirm step stays the authority, so continue is left enabled. + val budget = state.advancedBudgetSats + val fee = state.feeEstimate + val isAffordable = budget == null || fee == null || + order.clientBalanceSat.toLong() + fee <= budget.toLong() + val isValid = isInRange && isAffordable Content( uiState = state, diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 3a4187a1d7..e88142c8c5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -220,10 +220,61 @@ class TransferViewModel @Inject constructor( } } + /** + * Refreshes what the on-chain balance can still fund, so the advanced screen can reject a + * receiving capacity whose liquidity fee the user cannot pay. + */ + fun updateAdvancedFundingBudget() { + viewModelScope.launch { + val spendable = lightningRepo.getBalancesAsync().getOrNull()?.spendableOnchainBalanceSats + if (spendable == null) { + _spendingUiState.update { it.copy(advancedBudgetSats = null) } + return@launch + } + val miningFee = lightningRepo.estimateSendAllFee(speed = TransactionSpeed.Fast).getOrElse { + Logger.warn("Failed to estimate advanced transfer mining fee reserve", it, context = TAG) + (spendable.toDouble() * Defaults.fallbackFeePercent).toULong() + } + _spendingUiState.update { it.copy(advancedBudgetSats = spendable.safe() - miningFee.safe()) } + } + } + + /** + * Whether the order for this capacity still fits the funding budget. + * + * The receiving side is chosen independently of the client balance, and the LSP prices both + * sides, so raising it can push the order past what the wallet can pay. An unknown budget or + * quote leaves the decision to the confirm step rather than blocking the user here. + */ + private suspend fun canFundAdvancedOrder(clientBalance: ULong, receivingAmount: ULong): Boolean { + val budget = _spendingUiState.value.advancedBudgetSats ?: return true + val fee = blocktankRepo.estimateOrderFee( + spendingBalanceSats = clientBalance, + receivingBalanceSats = receivingAmount, + ).getOrNull()?.feeSat ?: return true + return clientBalance.safe() + fee.safe() <= budget + } + fun onSpendingAdvancedContinue(receivingAmountSats: Long) { viewModelScope.launch { runCatching { val oldOrder = _spendingUiState.value.order ?: return@launch + if (!canFundAdvancedOrder(oldOrder.clientBalanceSat, receivingAmountSats.toULong())) { + Logger.info( + "Rejected advanced capacity '$receivingAmountSats' over funding budget " + + "'${_spendingUiState.value.advancedBudgetSats}'", + context = TAG, + ) + setTransferEffect( + TransferEffect.ToastError( + title = context.getString(R.string.lightning__spending_advanced__error_balance__title), + description = context.getString( + R.string.lightning__spending_advanced__error_balance__description + ), + ) + ) + return@launch + } val newOrder = blocktankRepo.createOrder( spendingBalanceSats = oldOrder.clientBalanceSat, receivingBalanceSats = receivingAmountSats.toULong(), @@ -1757,6 +1808,8 @@ data class TransferToSpendingUiState( val feeEstimate: Long? = null, /** Budget the transfer limits were sized against, or null while unknown. */ val fundingBudgetSats: ULong? = null, + /** Total order cost the on-chain balance can fund, or null while unknown. */ + val advancedBudgetSats: ULong? = null, ) private data class SpendingConfirmFundingPlan( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 563d4ce358..bd31c0b64f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -359,6 +359,8 @@ Please wait, your funds transfer is in progress. This should take <accent>±10 minutes.</accent> Spendable Onchain Spending + Your savings cannot cover the liquidity fee for this receiving capacity. Choose a smaller amount. + Not Enough Funds The receiving capacity is currently limited to ₿ {amount}. Receiving Capacity Maximum Liquidity fee diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index b90b64d428..ec06a52842 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -673,6 +673,77 @@ class TransferViewModelTest : BaseUnitTest() { assertEquals(999uL, sut.spendingUiState.value.hwMiningFeeSats) } + @Test + fun `updateAdvancedFundingBudget reserves the fast mining fee from spendable`() = test { + val spendable = 300_000uL + val miningFee = 178uL + stubSpendableBalances(spendable) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(miningFee)) + + sut.updateAdvancedFundingBudget() + advanceUntilIdle() + + assertEquals(spendable - miningFee, sut.spendingUiState.value.advancedBudgetSats) + } + + @Test + fun `onSpendingAdvancedContinue rejects a receiving capacity the balance cannot fund`() = test { + val clientBalance = 260_000uL + val order = previewBtOrder(clientBalanceSat = clientBalance) + val budget = 265_000uL + // client balance plus this liquidity fee lands above the budget + val response = stubFeeResponse(6_000uL) + stubSpendableBalances(budget) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(0uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) + whenever(blocktankRepo.estimateOrderFee(eq(clientBalance), any(), any())) + .thenReturn(Result.success(response)) + sut.onConfirmAmount(clientBalance.toLong()) + advanceUntilIdle() + sut.updateAdvancedFundingBudget() + advanceUntilIdle() + + sut.transferEffects.test { + sut.onSpendingAdvancedContinue(LSP_BALANCE.toLong()) + advanceUntilIdle() + + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + // only the initial order from onConfirmAmount, no unaffordable one on top of it + verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) + } + + @Test + fun `onSpendingAdvancedContinue creates the order when the capacity fits the budget`() = test { + val clientBalance = 260_000uL + val order = previewBtOrder(clientBalanceSat = clientBalance) + val budget = 265_000uL + val response = stubFeeResponse(1_000uL) + stubSpendableBalances(budget) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(0uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) + whenever(blocktankRepo.estimateOrderFee(eq(clientBalance), any(), any())) + .thenReturn(Result.success(response)) + sut.onConfirmAmount(clientBalance.toLong()) + advanceUntilIdle() + sut.updateAdvancedFundingBudget() + advanceUntilIdle() + + sut.onSpendingAdvancedContinue(LSP_BALANCE.toLong()) + advanceUntilIdle() + + assertTrue(sut.spendingUiState.value.isAdvanced) + verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) + } + @Test fun `prepareSpendingConfirmFunding exposes real mining fee for confirm UI`() = test { val order = previewBtOrder(feeSat = 98_000uL) From 185ea67dffd28a57a7867caa4be39988ea7e48fe Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 25 Aug 2026 08:33:05 -0300 Subject: [PATCH 02/12] fix: handle not loaded budget --- .../to/bitkit/viewmodels/TransferViewModel.kt | 53 +++++++++++++------ .../viewmodels/TransferViewModelTest.kt | 50 +++++++++++++++++ 2 files changed, 87 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index e88142c8c5..76db3a029f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -199,6 +199,10 @@ class TransferViewModel @Inject constructor( if (!isValid) return@launch + if (_spendingUiState.value.advancedBudgetSats == null) { + _spendingUiState.update { it.copy(advancedBudgetSats = loadFundingBudget()) } + } + val result = blocktankRepo.estimateOrderFee( spendingBalanceSats = _spendingUiState.value.order?.clientBalanceSat ?: 0u, receivingBalanceSats = amount.toULong(), @@ -221,37 +225,54 @@ class TransferViewModel @Inject constructor( } /** - * Refreshes what the on-chain balance can still fund, so the advanced screen can reject a + * Order cost the on-chain balance can still fund, or null when the balance itself is unreadable. + * + * Both reads are local to the node, so this is cheap enough to resolve on demand rather than + * relying on a cached value that may be missing or stale. + */ + private suspend fun loadFundingBudget(): ULong? { + val spendable = lightningRepo.getBalancesAsync().getOrNull()?.spendableOnchainBalanceSats ?: return null + val miningFee = lightningRepo.estimateSendAllFee(speed = TransactionSpeed.Fast).getOrElse { + Logger.warn("Failed to estimate advanced transfer mining fee reserve", it, context = TAG) + (spendable.toDouble() * Defaults.fallbackFeePercent).toULong() + } + return spendable.safe() - miningFee.safe() + } + + /** + * Refreshes what the on-chain balance can still fund, so the advanced screen can disable a * receiving capacity whose liquidity fee the user cannot pay. */ fun updateAdvancedFundingBudget() { viewModelScope.launch { - val spendable = lightningRepo.getBalancesAsync().getOrNull()?.spendableOnchainBalanceSats - if (spendable == null) { - _spendingUiState.update { it.copy(advancedBudgetSats = null) } - return@launch - } - val miningFee = lightningRepo.estimateSendAllFee(speed = TransactionSpeed.Fast).getOrElse { - Logger.warn("Failed to estimate advanced transfer mining fee reserve", it, context = TAG) - (spendable.toDouble() * Defaults.fallbackFeePercent).toULong() - } - _spendingUiState.update { it.copy(advancedBudgetSats = spendable.safe() - miningFee.safe()) } + _spendingUiState.update { it.copy(advancedBudgetSats = loadFundingBudget()) } } } /** - * Whether the order for this capacity still fits the funding budget. + * Whether the order for this capacity still fits what the wallet can fund. * * The receiving side is chosen independently of the client balance, and the LSP prices both - * sides, so raising it can push the order past what the wallet can pay. An unknown budget or - * quote leaves the decision to the confirm step rather than blocking the user here. + * sides, so raising it can push the order past what the wallet can pay. The budget is resolved + * here rather than read from state, so a cached value that never loaded or went stale while the + * screen was open cannot wave an unaffordable order through. Only a balance the node will not + * report, or a quote the LSP will not give, defers the decision to the confirm step. */ private suspend fun canFundAdvancedOrder(clientBalance: ULong, receivingAmount: ULong): Boolean { - val budget = _spendingUiState.value.advancedBudgetSats ?: return true + val budget = loadFundingBudget() + if (budget == null) { + Logger.warn("Skipped advanced capacity check, on-chain balance unavailable", context = TAG) + return true + } + _spendingUiState.update { it.copy(advancedBudgetSats = budget) } val fee = blocktankRepo.estimateOrderFee( spendingBalanceSats = clientBalance, receivingBalanceSats = receivingAmount, - ).getOrNull()?.feeSat ?: return true + ).getOrNull()?.feeSat + if (fee == null) { + Logger.warn("Skipped advanced capacity check, fee quote unavailable", context = TAG) + return true + } return clientBalance.safe() + fee.safe() <= budget } diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index ec06a52842..b46fab5c81 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -744,6 +744,56 @@ class TransferViewModelTest : BaseUnitTest() { verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) } + @Test + fun `onSpendingAdvancedContinue rejects an unaffordable capacity without a cached budget`() = test { + val clientBalance = 260_000uL + val order = previewBtOrder(clientBalanceSat = clientBalance) + val response = stubFeeResponse(6_000uL) + stubSpendableBalances(265_000uL) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(0uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) + whenever(blocktankRepo.estimateOrderFee(eq(clientBalance), any(), any())) + .thenReturn(Result.success(response)) + sut.onConfirmAmount(clientBalance.toLong()) + advanceUntilIdle() + // deliberately no updateAdvancedFundingBudget call, so the cached budget stays null + assertNull(sut.spendingUiState.value.advancedBudgetSats) + + sut.transferEffects.test { + sut.onSpendingAdvancedContinue(LSP_BALANCE.toLong()) + advanceUntilIdle() + + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) + } + + @Test + fun `onSpendingAdvancedContinue proceeds when the on-chain balance cannot be read`() = test { + val clientBalance = 260_000uL + val order = previewBtOrder(clientBalanceSat = clientBalance) + val response = stubFeeResponse(6_000uL) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) + whenever(blocktankRepo.estimateOrderFee(eq(clientBalance), any(), any())) + .thenReturn(Result.success(response)) + sut.onConfirmAmount(clientBalance.toLong()) + advanceUntilIdle() + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.failure(AppError("node unavailable"))) + + sut.onSpendingAdvancedContinue(LSP_BALANCE.toLong()) + advanceUntilIdle() + + // an unreadable balance must not block the user; confirm stays the authority + assertTrue(sut.spendingUiState.value.isAdvanced) + verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) + } + @Test fun `prepareSpendingConfirmFunding exposes real mining fee for confirm UI`() = test { val order = previewBtOrder(feeSat = 98_000uL) From c5a66a9000ea9bed5a1d75356dfa932ea70c18d7 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 25 Aug 2026 08:35:18 -0300 Subject: [PATCH 03/12] chore: add changelog fragment Co-Authored-By: Claude Opus 5 (1M context) --- changelog.d/next/1180.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/1180.fixed.md diff --git a/changelog.d/next/1180.fixed.md b/changelog.d/next/1180.fixed.md new file mode 100644 index 0000000000..77da5d2176 --- /dev/null +++ b/changelog.d/next/1180.fixed.md @@ -0,0 +1 @@ +Choosing a receiving capacity larger than your savings can pay for is now blocked up front instead of failing later on the transfer confirmation screen. From a614a442a0fc01445d2b3677b6e72489640b95f6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 25 Aug 2026 13:04:01 -0300 Subject: [PATCH 04/12] fix: reconcile advanced guard with confirm guard Co-Authored-By: Claude Opus 5 (1M context) --- .../to/bitkit/viewmodels/TransferViewModel.kt | 15 ----------- .../viewmodels/TransferViewModelTest.kt | 26 ++++++++++++------- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 76db3a029f..2433d47038 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -224,21 +224,6 @@ class TransferViewModel @Inject constructor( } } - /** - * Order cost the on-chain balance can still fund, or null when the balance itself is unreadable. - * - * Both reads are local to the node, so this is cheap enough to resolve on demand rather than - * relying on a cached value that may be missing or stale. - */ - private suspend fun loadFundingBudget(): ULong? { - val spendable = lightningRepo.getBalancesAsync().getOrNull()?.spendableOnchainBalanceSats ?: return null - val miningFee = lightningRepo.estimateSendAllFee(speed = TransactionSpeed.Fast).getOrElse { - Logger.warn("Failed to estimate advanced transfer mining fee reserve", it, context = TAG) - (spendable.toDouble() * Defaults.fallbackFeePercent).toULong() - } - return spendable.safe() - miningFee.safe() - } - /** * Refreshes what the on-chain balance can still fund, so the advanced screen can disable a * receiving capacity whose liquidity fee the user cannot pay. diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index b46fab5c81..a8a510fbdf 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -692,23 +692,27 @@ class TransferViewModelTest : BaseUnitTest() { val clientBalance = 260_000uL val order = previewBtOrder(clientBalanceSat = clientBalance) val budget = 265_000uL - // client balance plus this liquidity fee lands above the budget - val response = stubFeeResponse(6_000uL) + val raisedCapacity = LSP_BALANCE * 2u + // the default capacity is affordable, the raised one is not + val affordable = stubFeeResponse(1_000uL) + val unaffordable = stubFeeResponse(6_000uL) stubSpendableBalances(budget) whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } .thenReturn(Result.success(0uL)) whenever(blocktankRepo.calculateLiquidityOptions(any())) .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) - whenever(blocktankRepo.estimateOrderFee(eq(clientBalance), any(), any())) - .thenReturn(Result.success(response)) + whenever(blocktankRepo.estimateOrderFee(eq(clientBalance), eq(LSP_BALANCE), any())) + .thenReturn(Result.success(affordable)) + whenever(blocktankRepo.estimateOrderFee(eq(clientBalance), eq(raisedCapacity), any())) + .thenReturn(Result.success(unaffordable)) sut.onConfirmAmount(clientBalance.toLong()) advanceUntilIdle() sut.updateAdvancedFundingBudget() advanceUntilIdle() sut.transferEffects.test { - sut.onSpendingAdvancedContinue(LSP_BALANCE.toLong()) + sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) advanceUntilIdle() assertIs(awaitItem()) @@ -748,22 +752,26 @@ class TransferViewModelTest : BaseUnitTest() { fun `onSpendingAdvancedContinue rejects an unaffordable capacity without a cached budget`() = test { val clientBalance = 260_000uL val order = previewBtOrder(clientBalanceSat = clientBalance) - val response = stubFeeResponse(6_000uL) + val raisedCapacity = LSP_BALANCE * 2u + val affordable = stubFeeResponse(1_000uL) + val unaffordable = stubFeeResponse(6_000uL) stubSpendableBalances(265_000uL) whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } .thenReturn(Result.success(0uL)) whenever(blocktankRepo.calculateLiquidityOptions(any())) .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) - whenever(blocktankRepo.estimateOrderFee(eq(clientBalance), any(), any())) - .thenReturn(Result.success(response)) + whenever(blocktankRepo.estimateOrderFee(eq(clientBalance), eq(LSP_BALANCE), any())) + .thenReturn(Result.success(affordable)) + whenever(blocktankRepo.estimateOrderFee(eq(clientBalance), eq(raisedCapacity), any())) + .thenReturn(Result.success(unaffordable)) sut.onConfirmAmount(clientBalance.toLong()) advanceUntilIdle() // deliberately no updateAdvancedFundingBudget call, so the cached budget stays null assertNull(sut.spendingUiState.value.advancedBudgetSats) sut.transferEffects.test { - sut.onSpendingAdvancedContinue(LSP_BALANCE.toLong()) + sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) advanceUntilIdle() assertIs(awaitItem()) From 455e6c0d29134afca3d757410ef2eaccac72148e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 26 Aug 2026 09:47:46 -0300 Subject: [PATCH 05/12] refactor: use runSuspendCatching --- app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt | 6 +++--- 1 file changed, 3 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 2433d47038..ae574b0d89 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -263,8 +263,8 @@ class TransferViewModel @Inject constructor( fun onSpendingAdvancedContinue(receivingAmountSats: Long) { viewModelScope.launch { - runCatching { - val oldOrder = _spendingUiState.value.order ?: return@launch + runSuspendCatching { + val oldOrder = _spendingUiState.value.order ?: return@runSuspendCatching if (!canFundAdvancedOrder(oldOrder.clientBalanceSat, receivingAmountSats.toULong())) { Logger.info( "Rejected advanced capacity '$receivingAmountSats' over funding budget " + @@ -279,7 +279,7 @@ class TransferViewModel @Inject constructor( ), ) ) - return@launch + return@runSuspendCatching } val newOrder = blocktankRepo.createOrder( spendingBalanceSats = oldOrder.clientBalanceSat, From 4c595604f885fa5a6fde553e909eb4d3f2793750 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 26 Aug 2026 10:21:18 -0300 Subject: [PATCH 06/12] fix: drop stale receiving capacity fee quotes --- .../to/bitkit/viewmodels/TransferViewModel.kt | 9 ++++- .../viewmodels/TransferViewModelTest.kt | 40 +++++++++++++++---- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index ae574b0d89..4730a1aab8 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -120,6 +120,7 @@ class TransferViewModel @Inject constructor( private var hwFeeEstimateJob: Job? = null private var confirmFeeJob: Job? = null private var confirmPayJob: Job? = null + private var receivingFeeQuoteJob: Job? = null private var spendingConfirmFundingPlan: SpendingConfirmFundingPlan? = null private var pendingHwFundingBroadcast: PendingHwFundingBroadcast? = null private var activeHwTransferWalletId: String? = null @@ -185,8 +186,14 @@ class TransferViewModel @Inject constructor( updateAvailableAmount() } + /** + * Re-price the receiving capacity the user typed. + * Cancels any in-flight quote so a slower earlier request cannot overwrite a newer one, which + * would otherwise leave the fee gating the continue button pinned to an amount already left. + */ fun onReceivingAmountChange(amount: Long) { - viewModelScope.launch { + receivingFeeQuoteJob?.cancel() + receivingFeeQuoteJob = viewModelScope.launch { _spendingUiState.update { it.copy(receivingAmount = amount, feeEstimate = null) } if (amount == 0L) return@launch diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index a8a510fbdf..200be971dc 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -674,17 +674,41 @@ class TransferViewModelTest : BaseUnitTest() { } @Test - fun `updateAdvancedFundingBudget reserves the fast mining fee from spendable`() = test { - val spendable = 300_000uL - val miningFee = 178uL - stubSpendableBalances(spendable) - whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } - .thenReturn(Result.success(miningFee)) + fun `onReceivingAmountChange discards a slower quote for an amount already left`() = test { + val staleAmount = 900_000uL + val freshAmount = 300_000uL + val staleQuote = CompletableDeferred>() + val staleResponse = stubFeeResponse(6_000uL) + val freshResponse = stubFeeResponse(1_000uL) + whenever(blocktankRepo.calculateLiquidityOptions(any())).thenReturn( + Result.success( + ChannelLiquidityOptions( + defaultLspBalanceSat = LSP_BALANCE, + minLspBalanceSat = LSP_BALANCE, + maxLspBalanceSat = 1_000_000uL, + maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE, + ) + ) + ) + whenever(blocktankRepo.estimateOrderFee(any(), eq(staleAmount), any())) + .doSuspendableAnswer { staleQuote.await() } + whenever(blocktankRepo.estimateOrderFee(any(), eq(freshAmount), any())) + .thenReturn(Result.success(freshResponse)) + sut.updateLimits() + advanceUntilIdle() - sut.updateAdvancedFundingBudget() + sut.onReceivingAmountChange(staleAmount.toLong()) + runCurrent() + sut.onReceivingAmountChange(freshAmount.toLong()) + advanceUntilIdle() + + assertEquals(1_000L, sut.spendingUiState.value.feeEstimate) + + staleQuote.complete(Result.success(staleResponse)) advanceUntilIdle() - assertEquals(spendable - miningFee, sut.spendingUiState.value.advancedBudgetSats) + assertEquals(1_000L, sut.spendingUiState.value.feeEstimate) + assertEquals(freshAmount.toLong(), sut.spendingUiState.value.receivingAmount) } @Test From 8cdca15b547d12589c83cbc8610ab03be144252d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 26 Aug 2026 11:05:56 -0300 Subject: [PATCH 07/12] fix: re-read funding budget before placing order --- .../to/bitkit/viewmodels/TransferViewModel.kt | 47 ++++++++------- .../viewmodels/TransferViewModelTest.kt | 60 ++++++++++++++++++- 2 files changed, 85 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 4730a1aab8..943ac4a1a4 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -245,13 +245,13 @@ class TransferViewModel @Inject constructor( * Whether the order for this capacity still fits what the wallet can fund. * * The receiving side is chosen independently of the client balance, and the LSP prices both - * sides, so raising it can push the order past what the wallet can pay. The budget is resolved - * here rather than read from state, so a cached value that never loaded or went stale while the - * screen was open cannot wave an unaffordable order through. Only a balance the node will not - * report, or a quote the LSP will not give, defers the decision to the confirm step. + * sides, so raising it can push the order past what the wallet can pay. Like the confirm guard, + * this checks against [currentFundingBudget] so a hardware transfer is measured against the + * device account its funds actually sit in. A budget that was never sized, or a quote the LSP + * will not give, defers the decision to the confirm step rather than blocking the user here. */ private suspend fun canFundAdvancedOrder(clientBalance: ULong, receivingAmount: ULong): Boolean { - val budget = loadFundingBudget() + val budget = currentFundingBudget() if (budget == null) { Logger.warn("Skipped advanced capacity check, on-chain balance unavailable", context = TAG) return true @@ -265,7 +265,11 @@ class TransferViewModel @Inject constructor( Logger.warn("Skipped advanced capacity check, fee quote unavailable", context = TAG) return true } - return clientBalance.safe() + fee.safe() <= budget + val canFund = clientBalance.safe() + fee.safe() <= budget + if (!canFund) { + Logger.info("Priced advanced capacity '$receivingAmount' over funding budget '$budget'", context = TAG) + } + return canFund } fun onSpendingAdvancedContinue(receivingAmountSats: Long) { @@ -273,11 +277,7 @@ class TransferViewModel @Inject constructor( runSuspendCatching { val oldOrder = _spendingUiState.value.order ?: return@runSuspendCatching if (!canFundAdvancedOrder(oldOrder.clientBalanceSat, receivingAmountSats.toULong())) { - Logger.info( - "Rejected advanced capacity '$receivingAmountSats' over funding budget " + - "'${_spendingUiState.value.advancedBudgetSats}'", - context = TAG, - ) + Logger.info("Rejected advanced capacity '$receivingAmountSats' over funding budget", context = TAG) setTransferEffect( TransferEffect.ToastError( title = context.getString(R.string.lightning__spending_advanced__error_balance__title), @@ -680,7 +680,7 @@ class TransferViewModel @Inject constructor( awaitNodeRunning() val fundingBudget = loadFundingBudget() - _spendingUiState.update { it.copy(fundingBudgetSats = fundingBudget) } + _spendingUiState.update { it.copy(fundingBudgetSats = fundingBudget, isHwFundingBudget = false) } val availableAmount = fundingBudget ?: 0uL val initialLspFees = estimateInitialLspFees(availableAmount) @@ -822,18 +822,23 @@ class TransferViewModel @Inject constructor( return spendable.safe() - miningFee.safe() } + private suspend fun currentFundingBudget(): ULong? { + val sizedBudget = _spendingUiState.value.fundingBudgetSats + if (_spendingUiState.value.isHwFundingBudget) return sizedBudget + return loadFundingBudget() ?: sizedBudget + } + /** * Whether an order at [clientBalance] still fits what the wallet can fund. * * The advertised max can be a settled estimate rather than a verified one when a re-quote fails - * or does not converge, so the fee is re-quoted live before the order is placed. The budget is - * the one the limits were sized against, which is the on-chain balance for a soft wallet and the - * device account for a hardware transfer — a fresh on-chain read would reject every hardware - * transfer, whose funds never sit in this wallet. A budget that was never sized, or a quote the - * LSP will not give, leaves the decision to the confirm step rather than blocking the user here. + * or does not converge, so both sides are taken fresh before the order is placed: the fee is + * re-quoted and the budget comes from [currentFundingBudget]. A budget that was never sized, or + * a quote the LSP will not give, leaves the decision to the confirm step rather than blocking + * the user here. */ private suspend fun canFundOrder(clientBalance: ULong): Boolean { - val budget = _spendingUiState.value.fundingBudgetSats + val budget = currentFundingBudget() if (budget == null) { Logger.warn("Skipped funding check, no sized budget available", context = TAG) return true @@ -923,7 +928,7 @@ class TransferViewModel @Inject constructor( updateTransferValues(0uL) val availableAmount = account.balanceSats.safe() - hwFundingFeeReserve(account.balanceSats).safe() - _spendingUiState.update { it.copy(fundingBudgetSats = availableAmount) } + _spendingUiState.update { it.copy(fundingBudgetSats = availableAmount, isHwFundingBudget = true) } val initialLspFees = estimateInitialLspFees(availableAmount) if (initialLspFees == null) { @@ -1821,8 +1826,8 @@ data class TransferToSpendingUiState( val feeEstimate: Long? = null, /** Budget the transfer limits were sized against, or null while unknown. */ val fundingBudgetSats: ULong? = null, - /** Total order cost the on-chain balance can fund, or null while unknown. */ - val advancedBudgetSats: ULong? = null, + /** Whether the sized budget came from a hardware device account rather than this wallet. */ + val isHwFundingBudget: Boolean = false, ) private data class SpendingConfirmFundingPlan( diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 200be971dc..1fbbce1c3f 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -805,9 +805,67 @@ class TransferViewModelTest : BaseUnitTest() { } @Test - fun `onSpendingAdvancedContinue proceeds when the on-chain balance cannot be read`() = test { + fun `onConfirmAmount rejects an order the balance can no longer fund`() = test { + val amount = 260_000uL + val response = stubFeeResponse(1_000uL) + stubSpendableBalances(265_000uL) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(0uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) + sut.updateLimits() + advanceUntilIdle() + // the savings drain after the limits were sized + stubSpendableBalances(100_000uL) + + sut.transferEffects.test { + sut.onConfirmAmount(amount.toLong()) + advanceUntilIdle() + + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + verify(blocktankRepo, never()).createOrder(any(), any(), any()) + } + + @Test + fun `onSpendingAdvancedContinue rejects a capacity the drained balance can no longer fund`() = test { val clientBalance = 260_000uL val order = previewBtOrder(clientBalanceSat = clientBalance) + val raisedCapacity = LSP_BALANCE * 2u + val response = stubFeeResponse(1_000uL) + stubSpendableBalances(265_000uL) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(0uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) + sut.updateLimits() + advanceUntilIdle() + sut.onConfirmAmount(clientBalance.toLong()) + advanceUntilIdle() + // the savings drain after the order was placed, before the capacity is raised + stubSpendableBalances(100_000uL) + + sut.transferEffects.test { + sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) + advanceUntilIdle() + + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + // only the initial order, no raised one on top of it + verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) + } + + @Test + fun `onSpendingAdvancedContinue funds a hardware transfer from the device balance`() = test { + // Regression: the capacity check must not read on-chain savings here, or every hardware + // transfer is rejected because those funds live on the device. + val clientBalance = 100_000uL + val order = previewBtOrder(clientBalanceSat = clientBalance) val response = stubFeeResponse(6_000uL) whenever(blocktankRepo.calculateLiquidityOptions(any())) .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) From e965e9ab69b2f164aaad247f28d2ab79be1868d4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 26 Aug 2026 11:18:07 -0300 Subject: [PATCH 08/12] refactor: comments cleanup --- .../to/bitkit/viewmodels/TransferViewModel.kt | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 943ac4a1a4..aca18226a6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -186,11 +186,6 @@ class TransferViewModel @Inject constructor( updateAvailableAmount() } - /** - * Re-price the receiving capacity the user typed. - * Cancels any in-flight quote so a slower earlier request cannot overwrite a newer one, which - * would otherwise leave the fee gating the continue button pinned to an amount already left. - */ fun onReceivingAmountChange(amount: Long) { receivingFeeQuoteJob?.cancel() receivingFeeQuoteJob = viewModelScope.launch { @@ -231,25 +226,6 @@ class TransferViewModel @Inject constructor( } } - /** - * Refreshes what the on-chain balance can still fund, so the advanced screen can disable a - * receiving capacity whose liquidity fee the user cannot pay. - */ - fun updateAdvancedFundingBudget() { - viewModelScope.launch { - _spendingUiState.update { it.copy(advancedBudgetSats = loadFundingBudget()) } - } - } - - /** - * Whether the order for this capacity still fits what the wallet can fund. - * - * The receiving side is chosen independently of the client balance, and the LSP prices both - * sides, so raising it can push the order past what the wallet can pay. Like the confirm guard, - * this checks against [currentFundingBudget] so a hardware transfer is measured against the - * device account its funds actually sit in. A budget that was never sized, or a quote the LSP - * will not give, defers the decision to the confirm step rather than blocking the user here. - */ private suspend fun canFundAdvancedOrder(clientBalance: ULong, receivingAmount: ULong): Boolean { val budget = currentFundingBudget() if (budget == null) { From 7d5f7a48f136862a1b83dad9e84df5343a8dd8d4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 26 Aug 2026 13:18:08 -0300 Subject: [PATCH 09/12] fix: cap advanced max to affordable capacity --- .../transfer/SpendingAdvancedScreen.kt | 28 +++-- .../to/bitkit/viewmodels/TransferViewModel.kt | 119 +++++++++++++++++- .../viewmodels/TransferViewModelTest.kt | 79 ++++++++++++ changelog.d/next/1180.fixed.md | 2 +- 4 files changed, 212 insertions(+), 16 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 13d029a503..ca285234e8 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 @@ -78,8 +78,7 @@ fun SpendingAdvancedScreen( val currentCurrencies by rememberUpdatedState(currencies) LaunchedEffect(order.clientBalanceSat) { - viewModel.updateTransferValues(order.clientBalanceSat) - viewModel.updateAdvancedFundingBudget() + viewModel.updateAdvancedTransferValues(order) } LaunchedEffect(amountUiState.sats) { @@ -134,19 +133,13 @@ fun SpendingAdvancedScreen( val amount = amountUiState.sats.toULong() amount > 0u && it.maxLspBalance > 0u && amount in it.minLspBalance..it.maxLspBalance } - // Max sets the capacity from the LSP's liquidity limit, which ignores what the wallet can pay - // for. Until the quote lands the confirm step stays the authority, so continue is left enabled. - val budget = state.advancedBudgetSats - val fee = state.feeEstimate - val isAffordable = budget == null || fee == null || - order.clientBalanceSat.toLong() + fee <= budget.toLong() - val isValid = isInRange && isAffordable + val isValid = isInRange && state.canAfford(order.clientBalanceSat) Content( uiState = state, transferValues = transferValues, isValid = isValid, - isLoading = isLoading, + isLoading = isLoading || state.isLoading, amountInputViewModel = amountInputViewModel, currencies = currencies, onBack = onBackClick, @@ -157,6 +150,17 @@ fun SpendingAdvancedScreen( ) } +/** + * The max is settled on an affordable capacity before it is offered, so the quote for the typed + * amount only has to catch what moves after that. Until it lands the confirm step is the authority, + * so continue is left enabled. + */ +private fun TransferToSpendingUiState.canAfford(clientBalanceSat: ULong): Boolean { + val budget = fundingBudgetSats ?: return true + val fee = feeEstimate ?: return true + return clientBalanceSat.toLong() + fee <= budget.toLong() +} + @Suppress("ViewModelForwarding") @Composable private fun Content( @@ -230,18 +234,21 @@ private fun Content( NumberPadActionButton( text = stringResource(R.string.common__min), color = Colors.Purple, + enabled = !isLoading, onClick = { amountInputViewModel.setSats(transferValues.minLspBalance.toLong(), currencies) }, modifier = Modifier.testTag("SpendingAdvancedMin") ) NumberPadActionButton( text = stringResource(R.string.common__default), color = Colors.Purple, + enabled = !isLoading, onClick = { amountInputViewModel.setSats(transferValues.defaultLspBalance.toLong(), currencies) }, modifier = Modifier.testTag("SpendingAdvancedDefault") ) NumberPadActionButton( text = stringResource(R.string.common__max), color = Colors.Purple, + enabled = !isLoading, onClick = { amountInputViewModel.setSats(transferValues.maxLspBalance.toLong(), currencies) }, modifier = Modifier.testTag("SpendingAdvancedMax") ) @@ -252,6 +259,7 @@ private fun Content( NumberPad( viewModel = amountInputViewModel, + enabled = !isLoading, currencies = currencies, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index aca18226a6..7b09f63636 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -121,6 +121,7 @@ class TransferViewModel @Inject constructor( private var confirmFeeJob: Job? = null private var confirmPayJob: Job? = null private var receivingFeeQuoteJob: Job? = null + private var advancedLimitsJob: Job? = null private var spendingConfirmFundingPlan: SpendingConfirmFundingPlan? = null private var pendingHwFundingBroadcast: PendingHwFundingBroadcast? = null private var activeHwTransferWalletId: String? = null @@ -232,11 +233,7 @@ class TransferViewModel @Inject constructor( Logger.warn("Skipped advanced capacity check, on-chain balance unavailable", context = TAG) return true } - _spendingUiState.update { it.copy(advancedBudgetSats = budget) } - val fee = blocktankRepo.estimateOrderFee( - spendingBalanceSats = clientBalance, - receivingBalanceSats = receivingAmount, - ).getOrNull()?.feeSat + val fee = quoteAdvancedOrderFee(clientBalance, receivingAmount) if (fee == null) { Logger.warn("Skipped advanced capacity check, fee quote unavailable", context = TAG) return true @@ -783,6 +780,80 @@ class TransferViewModel @Inject constructor( return fallback } + private suspend fun quoteAdvancedOrderFee(clientBalance: ULong, receivingAmount: ULong): ULong? = + blocktankRepo.estimateOrderFee( + spendingBalanceSats = clientBalance, + receivingBalanceSats = receivingAmount, + ).getOrNull()?.feeSat + + /** + * Largest receiving capacity whose order [clientBalance] can still fund, settled against live quotes. + * + * The LSP advertises the largest channel it will sell and prices the receiving side on top of + * the client balance, so that capacity can cost more than the budget leaves. Unlike the client + * balance, a satoshi off the capacity only takes a fraction of a satoshi off the fee, so each + * round re-prices through the rate the two bracketing quotes imply rather than stepping down by + * the shortfall. Every returned capacity has been priced and found affordable, so the max the + * user is offered is one the confirm guard accepts. Null means even [minLspBalance] is out of + * reach, leaving that rejection to the confirm step. + */ + private suspend fun resolveAffordableLspBalance( + clientBalance: ULong, + budget: ULong, + minLspBalance: ULong, + maxLspBalance: ULong, + ): ULong? { + val headroom = budget.safe() - clientBalance.safe() + val maxFee = quoteAdvancedOrderFee(clientBalance, maxLspBalance) ?: run { + Logger.warn("Advertising unsettled max capacity '$maxLspBalance', fee quote unavailable", context = TAG) + return maxLspBalance + } + if (maxFee <= headroom) return maxLspBalance + + val minFee = quoteAdvancedOrderFee(clientBalance, minLspBalance) + if (minFee == null || minFee > headroom) return null + + return settleCapacity( + clientBalance = clientBalance, + headroom = headroom, + affordable = minLspBalance, + affordableFee = minFee, + overBudget = maxLspBalance, + overBudgetFee = maxFee, + ) + } + + private suspend fun settleCapacity( + clientBalance: ULong, + headroom: ULong, + affordable: ULong, + affordableFee: ULong, + overBudget: ULong, + overBudgetFee: ULong, + ): ULong { + var settled = affordable + var settledFee = affordableFee + var ceiling = overBudget + var ceilingFee = overBudgetFee + repeat(MAX_AFFORDABILITY_ROUNDS) { + val feeSpan = ceilingFee.safe() - settledFee.safe() + if (feeSpan == 0uL) return settled + val span = ceiling.safe() - settled.safe() + val feeHeadroom = headroom.safe() - settledFee.safe() + val candidate = settled.safe() + ((span.safe() * feeHeadroom.safe()) / feeSpan).safe() + if (candidate <= settled) return settled + val candidateFee = quoteAdvancedOrderFee(clientBalance, candidate) ?: return settled + if (candidateFee <= headroom) { + settled = candidate + settledFee = candidateFee + } else { + ceiling = candidate + ceilingFee = candidateFee + } + } + return settled + } + /** * Order cost the on-chain balance can fund, or null when the balance itself is unreadable. * @@ -1302,6 +1373,44 @@ class TransferViewModel @Inject constructor( // region Balance Calc + /** + * Size the advanced capacity range for [order], with the max settled on what the wallet can pay. + * + * The LSP's advertised max ignores the client balance already committed to the order, so it can + * price an order the wallet cannot fund. Settling it here means the max button, and the ceiling + * the input enforces, land on a capacity that can actually be ordered rather than one the + * confirm guard rejects. + */ + fun updateAdvancedTransferValues(order: IBtOrder) { + advancedLimitsJob?.cancel() + advancedLimitsJob = viewModelScope.launch { + _spendingUiState.update { it.copy(isLoading = true) } + updateTransferValues(order.clientBalanceSat) + + val values = _transferValues.value + val budget = currentFundingBudget() + if (values.maxLspBalance == 0uL || budget == null) { + _spendingUiState.update { it.copy(isLoading = false) } + return@launch + } + + val affordableMax = resolveAffordableLspBalance( + clientBalance = order.clientBalanceSat, + budget = budget, + minLspBalance = values.minLspBalance, + maxLspBalance = values.maxLspBalance, + ) + if (affordableMax != null && affordableMax < values.maxLspBalance) { + Logger.info( + "Settled max capacity '${values.maxLspBalance}' on affordable '$affordableMax'", + context = TAG, + ) + _transferValues.update { it.copy(maxLspBalance = affordableMax) } + } + _spendingUiState.update { it.copy(isLoading = false) } + } + } + fun updateTransferValues(clientBalanceSat: ULong) { val options = blocktankRepo.calculateLiquidityOptions(clientBalanceSat).getOrNull() _transferValues.value = if (options != null) { diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 1fbbce1c3f..0e24582fa6 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -804,6 +804,67 @@ class TransferViewModelTest : BaseUnitTest() { verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) } + @Test + fun `updateAdvancedTransferValues settles the max on a capacity the balance can fund`() = test { + val order = previewBtOrder(clientBalanceSat = ADVANCED_CLIENT_BALANCE) + stubSpendableBalances(ADVANCED_BUDGET) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(0uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())).thenReturn( + Result.success(advancedLiquidityOptions(maxLspBalanceSat = 2_000_000uL)) + ) + stubCapacityPricedFees() + + sut.updateAdvancedTransferValues(order) + advanceUntilIdle() + + // fee is 1_000 + 1% of the capacity, and the budget leaves 10_000 over the client balance + assertEquals(900_000uL, sut.transferValues.value.maxLspBalance) + assertFalse(sut.spendingUiState.value.isLoading) + } + + @Test + fun `updateAdvancedTransferValues leaves an affordable max untouched`() = test { + val order = previewBtOrder(clientBalanceSat = ADVANCED_CLIENT_BALANCE) + stubSpendableBalances(ADVANCED_BUDGET) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(0uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())).thenReturn( + Result.success(advancedLiquidityOptions(maxLspBalanceSat = 400_000uL)) + ) + stubCapacityPricedFees() + + sut.updateAdvancedTransferValues(order) + advanceUntilIdle() + + assertEquals(400_000uL, sut.transferValues.value.maxLspBalance) + } + + @Test + fun `updateAdvancedTransferValues holds the loading state while settling the max`() = test { + val order = previewBtOrder(clientBalanceSat = ADVANCED_CLIENT_BALANCE) + val pendingQuote = CompletableDeferred>() + stubSpendableBalances(ADVANCED_BUDGET) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(0uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())).thenReturn( + Result.success(advancedLiquidityOptions(maxLspBalanceSat = 2_000_000uL)) + ) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).doSuspendableAnswer { + pendingQuote.await() + } + + sut.updateAdvancedTransferValues(order) + advanceUntilIdle() + + assertTrue(sut.spendingUiState.value.isLoading) + + pendingQuote.complete(Result.failure(AppError("no quote"))) + advanceUntilIdle() + + assertFalse(sut.spendingUiState.value.isLoading) + } + @Test fun `onConfirmAmount rejects an order the balance can no longer fund`() = test { val amount = 260_000uL @@ -2208,6 +2269,22 @@ class TransferViewModelTest : BaseUnitTest() { whenever(it.serviceFeeSat).thenReturn(0uL) } + private fun advancedLiquidityOptions(maxLspBalanceSat: ULong) = ChannelLiquidityOptions( + defaultLspBalanceSat = 100_000uL, + minLspBalanceSat = 50_000uL, + maxLspBalanceSat = maxLspBalanceSat, + maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE, + ) + + /** Prices an order at a flat 1_000 plus 1% of the receiving capacity, as the LSP charges both sides. */ + private suspend fun stubCapacityPricedFees() { + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).doSuspendableAnswer { invocation -> + // ULong params are erased to long across the mock boundary + val capacity = invocation.getArgument(1).toULong() + Result.success(stubFeeResponse(1_000uL + capacity / 100uL)) + } + } + private fun liquidityOptionsForCreate(maxClientBalanceSat: ULong) = ChannelLiquidityOptions( defaultLspBalanceSat = LSP_BALANCE, minLspBalanceSat = LSP_BALANCE, @@ -2259,6 +2336,8 @@ class TransferViewModelTest : BaseUnitTest() { 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 ADVANCED_CLIENT_BALANCE = 100_000uL + const val ADVANCED_BUDGET = 110_000uL 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/1180.fixed.md b/changelog.d/next/1180.fixed.md index 77da5d2176..080d2dbced 100644 --- a/changelog.d/next/1180.fixed.md +++ b/changelog.d/next/1180.fixed.md @@ -1 +1 @@ -Choosing a receiving capacity larger than your savings can pay for is now blocked up front instead of failing later on the transfer confirmation screen. +The advanced transfer screen now offers a maximum receiving capacity your balance can actually pay for, instead of one that fails later on the confirmation screen. From 5a0288fabde57f6187c6804579c3f4b7818e175e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 26 Aug 2026 13:37:24 -0300 Subject: [PATCH 10/12] fix: re-read device budget before placing order --- .../to/bitkit/viewmodels/TransferViewModel.kt | 24 ++-- .../viewmodels/TransferViewModelTest.kt | 129 ++++++++++++++---- 2 files changed, 113 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 7b09f63636..7a40f3d4d9 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -202,10 +202,6 @@ class TransferViewModel @Inject constructor( if (!isValid) return@launch - if (_spendingUiState.value.advancedBudgetSats == null) { - _spendingUiState.update { it.copy(advancedBudgetSats = loadFundingBudget()) } - } - val result = blocktankRepo.estimateOrderFee( spendingBalanceSats = _spendingUiState.value.order?.clientBalanceSat ?: 0u, receivingBalanceSats = amount.toULong(), @@ -230,7 +226,7 @@ class TransferViewModel @Inject constructor( private suspend fun canFundAdvancedOrder(clientBalance: ULong, receivingAmount: ULong): Boolean { val budget = currentFundingBudget() if (budget == null) { - Logger.warn("Skipped advanced capacity check, on-chain balance unavailable", context = TAG) + Logger.warn("Skipped advanced capacity check, no sized budget available", context = TAG) return true } val fee = quoteAdvancedOrderFee(clientBalance, receivingAmount) @@ -653,7 +649,7 @@ class TransferViewModel @Inject constructor( awaitNodeRunning() val fundingBudget = loadFundingBudget() - _spendingUiState.update { it.copy(fundingBudgetSats = fundingBudget, isHwFundingBudget = false) } + _spendingUiState.update { it.copy(fundingBudgetSats = fundingBudget, hwFundingWalletId = null) } val availableAmount = fundingBudget ?: 0uL val initialLspFees = estimateInitialLspFees(availableAmount) @@ -871,8 +867,14 @@ class TransferViewModel @Inject constructor( private suspend fun currentFundingBudget(): ULong? { val sizedBudget = _spendingUiState.value.fundingBudgetSats - if (_spendingUiState.value.isHwFundingBudget) return sizedBudget - return loadFundingBudget() ?: sizedBudget + val hwWalletId = _spendingUiState.value.hwFundingWalletId + val liveBudget = if (hwWalletId != null) loadHwFundingBudget(hwWalletId) else loadFundingBudget() + return liveBudget ?: sizedBudget + } + + private suspend fun loadHwFundingBudget(walletId: String): ULong? { + val balance = hwWalletRepo.getFundingAccount(walletId).getOrNull()?.balanceSats ?: return null + return balance.safe() - hwFundingFeeReserve(balance).safe() } /** @@ -975,7 +977,7 @@ class TransferViewModel @Inject constructor( updateTransferValues(0uL) val availableAmount = account.balanceSats.safe() - hwFundingFeeReserve(account.balanceSats).safe() - _spendingUiState.update { it.copy(fundingBudgetSats = availableAmount, isHwFundingBudget = true) } + _spendingUiState.update { it.copy(fundingBudgetSats = availableAmount, hwFundingWalletId = walletId) } val initialLspFees = estimateInitialLspFees(availableAmount) if (initialLspFees == null) { @@ -1911,8 +1913,8 @@ data class TransferToSpendingUiState( val feeEstimate: Long? = null, /** Budget the transfer limits were sized against, or null while unknown. */ val fundingBudgetSats: ULong? = null, - /** Whether the sized budget came from a hardware device account rather than this wallet. */ - val isHwFundingBudget: Boolean = false, + /** Hardware wallet the budget was sized from, or null when it came from this wallet's savings. */ + val hwFundingWalletId: String? = null, ) private data class SpendingConfirmFundingPlan( diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 0e24582fa6..bd7ef7ff7c 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -726,13 +726,12 @@ class TransferViewModelTest : BaseUnitTest() { whenever(blocktankRepo.calculateLiquidityOptions(any())) .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) - whenever(blocktankRepo.estimateOrderFee(eq(clientBalance), eq(LSP_BALANCE), any())) - .thenReturn(Result.success(affordable)) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(affordable)) whenever(blocktankRepo.estimateOrderFee(eq(clientBalance), eq(raisedCapacity), any())) .thenReturn(Result.success(unaffordable)) - sut.onConfirmAmount(clientBalance.toLong()) + sut.updateLimits() advanceUntilIdle() - sut.updateAdvancedFundingBudget() + sut.onConfirmAmount(clientBalance.toLong()) advanceUntilIdle() sut.transferEffects.test { @@ -758,11 +757,10 @@ class TransferViewModelTest : BaseUnitTest() { whenever(blocktankRepo.calculateLiquidityOptions(any())) .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) - whenever(blocktankRepo.estimateOrderFee(eq(clientBalance), any(), any())) - .thenReturn(Result.success(response)) - sut.onConfirmAmount(clientBalance.toLong()) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) + sut.updateLimits() advanceUntilIdle() - sut.updateAdvancedFundingBudget() + sut.onConfirmAmount(clientBalance.toLong()) advanceUntilIdle() sut.onSpendingAdvancedContinue(LSP_BALANCE.toLong()) @@ -773,35 +771,59 @@ class TransferViewModelTest : BaseUnitTest() { } @Test - fun `onSpendingAdvancedContinue rejects an unaffordable capacity without a cached budget`() = test { + fun `onSpendingAdvancedContinue proceeds when no budget was sized`() = test { val clientBalance = 260_000uL val order = previewBtOrder(clientBalanceSat = clientBalance) val raisedCapacity = LSP_BALANCE * 2u - val affordable = stubFeeResponse(1_000uL) + // a capacity the sized budget would have rejected, had the limits ever been sized val unaffordable = stubFeeResponse(6_000uL) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(unaffordable)) + // deliberately no updateLimits call, so the budget stays unsized + sut.onConfirmAmount(clientBalance.toLong()) + advanceUntilIdle() + assertNull(sut.spendingUiState.value.fundingBudgetSats) + + sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) + advanceUntilIdle() + + // an unsized budget must not block the user; confirm stays the authority + assertTrue(sut.spendingUiState.value.isAdvanced) + verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) + } + + @Test + fun `onSpendingAdvancedContinue proceeds when the capacity fee quote fails`() = test { + val clientBalance = 260_000uL + val order = previewBtOrder(clientBalanceSat = clientBalance) + val raisedCapacity = LSP_BALANCE * 2u + val affordable = stubFeeResponse(1_000uL) stubSpendableBalances(265_000uL) whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } .thenReturn(Result.success(0uL)) whenever(blocktankRepo.calculateLiquidityOptions(any())) .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) - whenever(blocktankRepo.estimateOrderFee(eq(clientBalance), eq(LSP_BALANCE), any())) - .thenReturn(Result.success(affordable)) - whenever(blocktankRepo.estimateOrderFee(eq(clientBalance), eq(raisedCapacity), any())) - .thenReturn(Result.success(unaffordable)) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(affordable)) + sut.updateLimits() + advanceUntilIdle() sut.onConfirmAmount(clientBalance.toLong()) advanceUntilIdle() - // deliberately no updateAdvancedFundingBudget call, so the cached budget stays null - assertNull(sut.spendingUiState.value.advancedBudgetSats) + // the budget is sized, so this is the failed-quote path rather than the unsized one + assertNotNull(sut.spendingUiState.value.fundingBudgetSats) - sut.transferEffects.test { - sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) - advanceUntilIdle() + // the LSP stops quoting only after the limits were sized + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())) + .thenReturn(Result.failure(AppError("lsp unreachable"))) - assertIs(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) + sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) + advanceUntilIdle() + + // a quote the LSP will not give must not block the user; confirm stays the authority + assertTrue(sut.spendingUiState.value.isAdvanced) + verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) } @Test @@ -921,26 +943,63 @@ class TransferViewModelTest : BaseUnitTest() { verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) } + @Test + fun `onSpendingAdvancedContinue rejects a capacity the drained device account cannot fund`() = test { + val clientBalance = 100_000uL + val order = previewBtOrder(clientBalanceSat = clientBalance) + val raisedCapacity = LSP_BALANCE * 2u + val response = stubFeeResponse(6_000uL) + stubSpendableBalances(0uL) // empty on-chain wallet, as in the hardware e2e + blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) + stubHwFundingAccount(balanceSats = ON_CHAIN_BALANCE) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(1uL)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) + sut.updateHwLimits(HARDWARE_WALLET_ID) + advanceUntilIdle() + sut.onConfirmAmount(clientBalance.toLong()) + advanceUntilIdle() + // the device account is spent from elsewhere after the limits were sized + stubHwFundingAccount(balanceSats = 50_000uL) + + sut.transferEffects.test { + sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) + advanceUntilIdle() + + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + // only the initial order, no raised one on top of it + verify(blocktankRepo, times(1)).createOrder(any(), any(), any()) + } + @Test fun `onSpendingAdvancedContinue funds a hardware transfer from the device balance`() = test { // Regression: the capacity check must not read on-chain savings here, or every hardware // transfer is rejected because those funds live on the device. val clientBalance = 100_000uL val order = previewBtOrder(clientBalanceSat = clientBalance) - val response = stubFeeResponse(6_000uL) + val raisedCapacity = LSP_BALANCE * 2u + // a fee the empty on-chain wallet could never cover, but the device account easily can + val deviceAffordable = stubFeeResponse(6_000uL) + stubSpendableBalances(0uL) // empty on-chain wallet, as in the hardware e2e + blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) + stubHwFundingAccount(balanceSats = ON_CHAIN_BALANCE) + whenever(lightningRepo.getFeeRateForSpeed(any(), anyOrNull())).thenReturn(Result.success(1uL)) whenever(blocktankRepo.calculateLiquidityOptions(any())) .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) whenever(blocktankRepo.createOrder(any(), any(), any())).thenReturn(Result.success(order)) - whenever(blocktankRepo.estimateOrderFee(eq(clientBalance), any(), any())) - .thenReturn(Result.success(response)) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(deviceAffordable)) + sut.updateHwLimits(HARDWARE_WALLET_ID) + advanceUntilIdle() sut.onConfirmAmount(clientBalance.toLong()) advanceUntilIdle() - whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.failure(AppError("node unavailable"))) - sut.onSpendingAdvancedContinue(LSP_BALANCE.toLong()) + sut.onSpendingAdvancedContinue(raisedCapacity.toLong()) advanceUntilIdle() - // an unreadable balance must not block the user; confirm stays the authority assertTrue(sut.spendingUiState.value.isAdvanced) verify(blocktankRepo, times(2)).createOrder(any(), any(), any()) } @@ -2298,6 +2357,18 @@ class TransferViewModelTest : BaseUnitTest() { return mock().also { whenever(it.options).thenReturn(options) } } + private suspend fun stubHwFundingAccount(balanceSats: ULong) { + whenever(hwWalletRepo.getFundingAccount(HARDWARE_WALLET_ID)).thenReturn( + Result.success( + HwFundingAccount.Trezor( + xpub = XPUB, + addressType = HwFundingAddressType.NATIVE_SEGWIT, + balanceSats = balanceSats, + ), + ), + ) + } + private suspend fun stubSpendableBalances(spendable: ULong) { val balances = BalanceDetails( totalOnchainBalanceSats = spendable, From a113f8a7e22b9ac6ec178a7dfb3775d8093c1938 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 26 Aug 2026 15:24:55 -0300 Subject: [PATCH 11/12] fix: clamp entered capacity to settled max --- .../transfer/SpendingAdvancedScreen.kt | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 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 ca285234e8..e125e82ae1 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 @@ -86,7 +86,11 @@ fun SpendingAdvancedScreen( } LaunchedEffect(transferValues.maxLspBalance) { - amountInputViewModel.setMaxAmount(transferValues.maxLspBalance.toLong()) + amountInputViewModel.applyMaxLspBalance( + maxLspBalance = transferValues.maxLspBalance.toLong(), + enteredSats = amountUiState.sats, + currencies = currentCurrencies, + ) } LaunchedEffect(Unit) { @@ -150,6 +154,21 @@ fun SpendingAdvancedScreen( ) } +/** + * Settling the max can land it below what is already entered, so the amount comes down with it + * rather than leaving a capacity that no longer exists selected. + */ +private fun AmountInputViewModel.applyMaxLspBalance( + maxLspBalance: Long, + enteredSats: Long, + currencies: CurrencyState, +) { + setMaxAmount(maxLspBalance) + if (maxLspBalance in 1.. Date: Wed, 26 Aug 2026 16:02:41 -0300 Subject: [PATCH 12/12] refactor: comments cleanup --- .../to/bitkit/viewmodels/TransferViewModel.kt | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 7a40f3d4d9..d3e3f97a9e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -782,17 +782,6 @@ class TransferViewModel @Inject constructor( receivingBalanceSats = receivingAmount, ).getOrNull()?.feeSat - /** - * Largest receiving capacity whose order [clientBalance] can still fund, settled against live quotes. - * - * The LSP advertises the largest channel it will sell and prices the receiving side on top of - * the client balance, so that capacity can cost more than the budget leaves. Unlike the client - * balance, a satoshi off the capacity only takes a fraction of a satoshi off the fee, so each - * round re-prices through the rate the two bracketing quotes imply rather than stepping down by - * the shortfall. Every returned capacity has been priced and found affordable, so the max the - * user is offered is one the confirm guard accepts. Null means even [minLspBalance] is out of - * reach, leaving that rejection to the confirm step. - */ private suspend fun resolveAffordableLspBalance( clientBalance: ULong, budget: ULong, @@ -1375,14 +1364,6 @@ class TransferViewModel @Inject constructor( // region Balance Calc - /** - * Size the advanced capacity range for [order], with the max settled on what the wallet can pay. - * - * The LSP's advertised max ignores the client balance already committed to the order, so it can - * price an order the wallet cannot fund. Settling it here means the max button, and the ceiling - * the input enforces, land on a capacity that can actually be ordered rather than one the - * confirm guard rejects. - */ fun updateAdvancedTransferValues(order: IBtOrder) { advancedLimitsJob?.cancel() advancedLimitsJob = viewModelScope.launch {