From f543097514b7cae1c52be031552b7c7a309be24c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 24 Aug 2026 15:07:08 -0300 Subject: [PATCH 01/11] fix: cap spending max at quoted lsp fee balance --- .../to/bitkit/viewmodels/TransferViewModel.kt | 5 ++- .../viewmodels/TransferViewModelTest.kt | 38 +++++++++++++++++++ changelog.d/next/899.fixed.md | 1 + 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 changelog.d/next/899.fixed.md diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 5333266eca..965c365fff 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -675,7 +675,10 @@ class TransferViewModel @Inject constructor( ).onSuccess { estimate -> maxLspFee = estimate.feeSat val lspFees = estimate.networkFeeSat.safe() + estimate.serviceFeeSat.safe() - val maxClientBalance = availableAmount.safe() - lspFees.safe() + // The fee was quoted for `cappedClientBalance`, and the LSP service fee grows with the + // client balance, so a larger balance derived from that quote prices an order costing + // more than the user has. Cap at the balance the fee was actually quoted for. + val maxClientBalance = minOf(availableAmount.safe() - lspFees.safe(), cappedClientBalance) val maxSend = min( liquidity.maxClientBalanceSat.toLong(), maxClientBalance.toLong() diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index a1f0f7d56c..6995ac81d8 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -233,6 +233,38 @@ class TransferViewModelTest : BaseUnitTest() { verify(blocktankRepo).estimateOrderFee(eq(availableAfterMining), any(), any()) } + @Test + fun `updateLimits caps spending max at the balance the LSP fee was quoted for`() = test { + // Real-world numbers from issue #899: the LSP service fee grows with the client balance, so + // the second (cheaper) quote must not be used to derive a larger balance than it priced. + val spendable = 265_904uL + val miningFee = 178uL + val availableAmount = spendable - miningFee + val initialLspFees = 4_165uL + val balanceAfterLspFee = availableAmount - initialLspFees + val finalLspFees = 4_128uL + val initialFeeResponse = stubFeeResponse(initialLspFees) + val finalFeeResponse = stubFeeResponse(finalLspFees) + stubSpendableBalances(spendable) + blocktankState.value = BlocktankState(info = null) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(miningFee)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = spendable))) + whenever(blocktankRepo.estimateOrderFee(eq(availableAmount), any(), any())) + .thenReturn(Result.success(initialFeeResponse)) + whenever(blocktankRepo.estimateOrderFee(eq(balanceAfterLspFee), any(), any())) + .thenReturn(Result.success(finalFeeResponse)) + + sut.updateLimits() + advanceUntilIdle() + + val maxAllowedToSend = sut.spendingUiState.value.maxAllowedToSend + assertEquals(balanceAfterLspFee.toLong(), maxAllowedToSend) + // The order the user can build at this max must stay within what they can actually pay. + assertTrue(maxAllowedToSend.toULong() + finalLspFees <= availableAmount) + } + @Test fun `updateLimits uses percent fallback when fast mining fee estimate fails`() = test { val spendable = 100_000uL @@ -1702,6 +1734,12 @@ class TransferViewModelTest : BaseUnitTest() { maxClientBalanceSat = maxClientBalanceSat, ) + private fun stubFeeResponse(lspFees: ULong): IBtEstimateFeeResponse2 = mock().also { + whenever(it.feeSat).thenReturn(lspFees) + whenever(it.networkFeeSat).thenReturn(lspFees) + whenever(it.serviceFeeSat).thenReturn(0uL) + } + private fun liquidityOptionsForCreate(maxClientBalanceSat: ULong) = ChannelLiquidityOptions( defaultLspBalanceSat = LSP_BALANCE, minLspBalanceSat = LSP_BALANCE, diff --git a/changelog.d/next/899.fixed.md b/changelog.d/next/899.fixed.md new file mode 100644 index 0000000000..74e99341e2 --- /dev/null +++ b/changelog.d/next/899.fixed.md @@ -0,0 +1 @@ +Transferring the maximum amount from Savings to Spending no longer fails with an insufficient funds error. From c309efb1438d06aabda75f1dc73f4f25e7aa8007 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 24 Aug 2026 15:09:29 -0300 Subject: [PATCH 02/11] chore: rename changelog fragment Co-Authored-By: Claude Opus 5 (1M context) --- changelog.d/next/{899.fixed.md => 1179.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{899.fixed.md => 1179.fixed.md} (100%) diff --git a/changelog.d/next/899.fixed.md b/changelog.d/next/1179.fixed.md similarity index 100% rename from changelog.d/next/899.fixed.md rename to changelog.d/next/1179.fixed.md From 07e10b11ffefbdf17d47af2c5d2b671ed0daee39 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 25 Aug 2026 07:14:42 -0300 Subject: [PATCH 03/11] fix: verify max transfer against a live fee quote --- .../to/bitkit/viewmodels/TransferViewModel.kt | 45 +++++++++++++++++-- .../viewmodels/TransferViewModelTest.kt | 34 ++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 965c365fff..759dc7f4d3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -675,10 +675,12 @@ class TransferViewModel @Inject constructor( ).onSuccess { estimate -> maxLspFee = estimate.feeSat val lspFees = estimate.networkFeeSat.safe() + estimate.serviceFeeSat.safe() - // The fee was quoted for `cappedClientBalance`, and the LSP service fee grows with the - // client balance, so a larger balance derived from that quote prices an order costing - // more than the user has. Cap at the balance the fee was actually quoted for. - val maxClientBalance = minOf(availableAmount.safe() - lspFees.safe(), cappedClientBalance) + val maxClientBalance = resolveAffordableClientBalance( + availableAmount = availableAmount, + receivingAmount = receivingAmount, + quotedBalance = cappedClientBalance, + quotedFee = lspFees, + ) val maxSend = min( liquidity.maxClientBalanceSat.toLong(), maxClientBalance.toLong() @@ -700,6 +702,38 @@ class TransferViewModel @Inject constructor( } } + /** + * Largest client balance that still covers its own order fee, settled against live quotes. + * + * [quotedFee] prices [quotedBalance], but the advertised max is usually a different balance, and + * the LSP charges the client and LSP sides of the channel at different rates. The fee at that + * other balance can therefore be higher, leaving an order the user cannot fund. Each round + * re-quotes and steps down by the shortfall; the fee moves by a small fraction of a satoshi per + * satoshi of balance, so this settles well within [MAX_AFFORDABILITY_ROUNDS]. + */ + private suspend fun resolveAffordableClientBalance( + availableAmount: ULong, + receivingAmount: ULong, + quotedBalance: ULong, + quotedFee: ULong, + ): ULong { + var candidate = quotedBalance + var fee = quotedFee + repeat(MAX_AFFORDABILITY_ROUNDS) { + if (candidate.safe() + fee.safe() <= availableAmount) return candidate + candidate = availableAmount.safe() - fee.safe() + fee = blocktankRepo.estimateOrderFee( + spendingBalanceSats = candidate, + receivingBalanceSats = receivingAmount, + ).getOrNull()?.let { it.networkFeeSat.safe() + it.serviceFeeSat.safe() } ?: return candidate + } + return if (candidate.safe() + fee.safe() <= availableAmount) { + candidate + } else { + availableAmount.safe() - fee.safe() + } + } + fun onUseDefaultLspBalanceClick() { val defaultOrder = _spendingUiState.value.defaultOrder hwFeeEstimateJob?.cancel() @@ -1581,6 +1615,9 @@ class TransferViewModel @Inject constructor( private const val POLL_INTERVAL_MS = 2_500L private const val MAX_CONSECUTIVE_ERRORS = 5 + /** Live re-quotes allowed while settling the advertised max transfer on an affordable balance. */ + private const val MAX_AFFORDABILITY_ROUNDS = 2 + /** Conservative vbyte reserve for multi-input hardware funding before exact compose runs. */ private const val HW_FUNDING_TX_VBYTES = 1_200uL diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 6995ac81d8..ae66de613b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -265,6 +265,40 @@ class TransferViewModelTest : BaseUnitTest() { assertTrue(maxAllowedToSend.toULong() + finalLspFees <= availableAmount) } + @Test + fun `updateLimits settles the spending max when the LSP fee falls as the client balance rises`() = test { + // Live quotes from the staging LSP, which charges the LSP side harder than the client side, + // so the second quote is dearer than the first and no ordering assumption can hold. + val spendable = 266_656uL + val miningFee = 178uL + val availableAmount = spendable - miningFee // 266_478 + val quotes = mapOf( + 266_478uL to 1_798uL, // f(A) -> balanceAfterLspFee = 264_680 + 264_680uL to 1_800uL, // f(C) -> first candidate is unaffordable + 264_678uL to 1_801uL, // round 1 -> still one sat over + 264_677uL to 1_801uL, // round 2 -> affordable + ) + stubSpendableBalances(spendable) + blocktankState.value = BlocktankState(info = null) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(miningFee)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = spendable))) + val responses = quotes.mapValues { (_, fee) -> stubFeeResponse(fee) } + responses.forEach { (balance, response) -> + whenever(blocktankRepo.estimateOrderFee(eq(balance), any(), any())) + .thenReturn(Result.success(response)) + } + + sut.updateLimits() + advanceUntilIdle() + + val maxAllowedToSend = sut.spendingUiState.value.maxAllowedToSend + assertEquals(264_677L, maxAllowedToSend) + // The settled max must fund its own order rather than merely undercut the first quote. + assertTrue(maxAllowedToSend.toULong() + quotes.getValue(maxAllowedToSend.toULong()) <= availableAmount) + } + @Test fun `updateLimits uses percent fallback when fast mining fee estimate fails`() = test { val spendable = 100_000uL From abde1571d9d689a354ce7e00ed33b5e148f8081a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 25 Aug 2026 07:21:23 -0300 Subject: [PATCH 04/11] chore: update changelog --- changelog.d/next/1179.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/next/1179.fixed.md b/changelog.d/next/1179.fixed.md index 74e99341e2..06c14fef3d 100644 --- a/changelog.d/next/1179.fixed.md +++ b/changelog.d/next/1179.fixed.md @@ -1 +1 @@ -Transferring the maximum amount from Savings to Spending no longer fails with an insufficient funds error. +The maximum Savings to Spending transfer amount now always leaves room for its own service fee, so transferring your full balance no longer fails with an insufficient funds error. From 3b57f1464de702f6b3333073f085a55867fdf5f0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 25 Aug 2026 11:27:53 -0300 Subject: [PATCH 05/11] fix: make updateLimits re-quotes against the channel split the order will actually use --- .../to/bitkit/viewmodels/TransferViewModel.kt | 40 ++++++++++++++----- .../viewmodels/TransferViewModelTest.kt | 39 ++++++++++++++++++ 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 759dc7f4d3..5581c8bd8b 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -677,7 +677,6 @@ class TransferViewModel @Inject constructor( val lspFees = estimate.networkFeeSat.safe() + estimate.serviceFeeSat.safe() val maxClientBalance = resolveAffordableClientBalance( availableAmount = availableAmount, - receivingAmount = receivingAmount, quotedBalance = cappedClientBalance, quotedFee = lspFees, ) @@ -713,7 +712,6 @@ class TransferViewModel @Inject constructor( */ private suspend fun resolveAffordableClientBalance( availableAmount: ULong, - receivingAmount: ULong, quotedBalance: ULong, quotedFee: ULong, ): ULong { @@ -722,16 +720,36 @@ class TransferViewModel @Inject constructor( repeat(MAX_AFFORDABILITY_ROUNDS) { if (candidate.safe() + fee.safe() <= availableAmount) return candidate candidate = availableAmount.safe() - fee.safe() - fee = blocktankRepo.estimateOrderFee( - spendingBalanceSats = candidate, - receivingBalanceSats = receivingAmount, - ).getOrNull()?.let { it.networkFeeSat.safe() + it.serviceFeeSat.safe() } ?: return candidate - } - return if (candidate.safe() + fee.safe() <= availableAmount) { - candidate - } else { - availableAmount.safe() - fee.safe() + fee = quoteOrderFee(candidate) ?: run { + Logger.warn( + "Advertising unverified max '$candidate', fee quote unavailable", + context = TAG, + ) + return candidate + } } + if (candidate.safe() + fee.safe() <= availableAmount) return candidate + + val fallback = availableAmount.safe() - fee.safe() + Logger.warn( + "Max '$candidate' still over budget '$availableAmount' after " + + "'$MAX_AFFORDABILITY_ROUNDS' rounds, advertising unverified '$fallback'", + context = TAG, + ) + return fallback + } + + /** + * LSP fee for an order at [clientBalance], priced against the channel split that order creation + * will pick for that same balance, so the settled max is checked against the order it produces. + */ + private suspend fun quoteOrderFee(clientBalance: ULong): ULong? { + val liquidity = blocktankRepo.calculateLiquidityOptions(clientBalance).getOrNull() ?: return null + val receivingAmount = maxOf(liquidity.defaultLspBalanceSat, liquidity.minLspBalanceSat) + return blocktankRepo.estimateOrderFee( + spendingBalanceSats = clientBalance, + receivingBalanceSats = receivingAmount, + ).getOrNull()?.let { it.networkFeeSat.safe() + it.serviceFeeSat.safe() } } fun onUseDefaultLspBalanceClick() { diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index ae66de613b..a402d9340c 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -299,6 +299,45 @@ class TransferViewModelTest : BaseUnitTest() { assertTrue(maxAllowedToSend.toULong() + quotes.getValue(maxAllowedToSend.toULong()) <= availableAmount) } + @Test + fun `updateLimits re-quotes against the channel split the order will actually use`() = test { + // Order creation recomputes the LSP balance from the chosen amount, so a re-quote priced + // against the earlier balance would verify an order that is never created. + val maxChannel = 1_403_872uL + val spendable = 266_656uL + val miningFee = 178uL + val availableAmount = spendable - miningFee // 266_478 + val quotes = mapOf(266_478uL to 1_798uL, 264_680uL to 1_800uL, 264_678uL to 1_801uL, 264_677uL to 1_801uL) + val responses = quotes.mapValues { (_, fee) -> stubFeeResponse(fee) } + stubSpendableBalances(spendable) + blocktankState.value = BlocktankState(info = null) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(miningFee)) + responses.forEach { (balance, response) -> + // each client balance gets its own LSP side, mirroring maxChannelSize - clientBalance + whenever(blocktankRepo.calculateLiquidityOptions(eq(balance))).thenReturn( + Result.success( + ChannelLiquidityOptions( + defaultLspBalanceSat = maxChannel - balance, + minLspBalanceSat = maxChannel - balance, + maxLspBalanceSat = maxChannel - balance, + maxClientBalanceSat = spendable, + ) + ) + ) + whenever(blocktankRepo.estimateOrderFee(eq(balance), any(), any())) + .thenReturn(Result.success(response)) + } + + sut.updateLimits() + advanceUntilIdle() + + assertEquals(264_677L, sut.spendingUiState.value.maxAllowedToSend) + // the re-quote must price 264_678 against its own split, not the one taken at 264_680 + verify(blocktankRepo).estimateOrderFee(eq(264_678uL), eq(maxChannel - 264_678uL), any()) + verify(blocktankRepo, never()).estimateOrderFee(eq(264_678uL), eq(maxChannel - 264_680uL), any()) + } + @Test fun `updateLimits uses percent fallback when fast mining fee estimate fails`() = test { val spendable = 100_000uL From f96dfc60633d56148c4c92ec0d5602eed5df8db1 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 25 Aug 2026 11:37:12 -0300 Subject: [PATCH 06/11] refactor: code cleanup --- app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 5581c8bd8b..8f32c880fe 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -116,7 +116,6 @@ class TransferViewModel @Inject constructor( val transferEffects = MutableSharedFlow() fun setTransferEffect(effect: TransferEffect) = viewModelScope.launch { transferEffects.emit(effect) } - var maxLspFee = 0uL private var hwTransferSignJob: Job? = null private var hwFeeEstimateJob: Job? = null private var confirmFeeJob: Job? = null @@ -673,7 +672,6 @@ class TransferViewModel @Inject constructor( spendingBalanceSats = cappedClientBalance, receivingBalanceSats = receivingAmount, ).onSuccess { estimate -> - maxLspFee = estimate.feeSat val lspFees = estimate.networkFeeSat.safe() + estimate.serviceFeeSat.safe() val maxClientBalance = resolveAffordableClientBalance( availableAmount = availableAmount, From 688db3b8df2b5745f9b98981bd39e51a501183c3 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 25 Aug 2026 12:02:37 -0300 Subject: [PATCH 07/11] fix: add a guard on confirm amount checking if an order still fits what the wallet can fund --- .../to/bitkit/viewmodels/TransferViewModel.kt | 46 ++++++++ app/src/main/res/values/strings.xml | 2 + .../viewmodels/TransferViewModelTest.kt | 108 ++++++++++++++++++ 3 files changed, 156 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 8f32c880fe..d53c1cbbdb 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -149,6 +149,20 @@ class TransferViewModel @Inject constructor( isNodeRunning.first { it } } + if (!canFundOrder(satsAmount.toULong())) { + Logger.info("Rejected spending amount '$satsAmount' over funding budget", context = TAG) + setTransferEffect( + TransferEffect.ToastError( + title = context.getString(R.string.lightning__spending_amount__error_balance__title), + description = context.getString( + R.string.lightning__spending_amount__error_balance__description + ), + ) + ) + _spendingUiState.update { it.copy(isLoading = false) } + return@launch + } + blocktankRepo.createOrder( spendingBalanceSats = satsAmount.toULong(), receivingBalanceSats = lspBalance, @@ -737,6 +751,38 @@ class TransferViewModel @Inject constructor( return fallback } + /** Order cost the on-chain balance can fund, or null when the balance itself is unreadable. */ + 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 transfer mining fee reserve", it, context = TAG) + (spendable.toDouble() * Defaults.fallbackFeePercent).toULong() + } + return spendable.safe() - miningFee.safe() + } + + /** + * 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, and the balance can move after it was sized, so the order is checked + * against a live quote before it is placed. An unknown budget or quote leaves the decision to + * the confirm step rather than blocking the user here. + */ + private suspend fun canFundOrder(clientBalance: ULong): Boolean { + val budget = loadFundingBudget() + if (budget == null) { + Logger.warn("Skipped funding check, on-chain balance unavailable", context = TAG) + return true + } + val fee = quoteOrderFee(clientBalance) + if (fee == null) { + Logger.warn("Skipped funding check, fee quote unavailable", context = TAG) + return true + } + return clientBalance.safe() + fee.safe() <= budget + } + /** * LSP fee for an order at [clientBalance], priced against the channel split that order creation * will pick for that same balance, so the settled max is checked against the order it produces. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f236296fb..563d4ce358 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -363,6 +363,8 @@ Receiving Capacity Maximum Liquidity fee Receiving\n<accent>capacity</accent> + Your savings cannot cover this transfer and its fees. Try a smaller amount. + Insufficient Savings The amount you can transfer to your spending balance is currently limited to ₿ {amount}. Your transfer to the spending balance is limited due to liquidity policy. For details, visit the Help Center. Spending Balance Maximum diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index a402d9340c..0b69dd755b 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -1,6 +1,7 @@ package to.bitkit.viewmodels import android.content.Context +import app.cash.turbine.test import com.synonym.bitkitcore.BoltzPairInfo import com.synonym.bitkitcore.BoltzSwapEvent import com.synonym.bitkitcore.BroadcastException @@ -82,6 +83,7 @@ import to.bitkit.utils.AppError import kotlin.math.roundToLong import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -338,6 +340,112 @@ class TransferViewModelTest : BaseUnitTest() { verify(blocktankRepo, never()).estimateOrderFee(eq(264_678uL), eq(maxChannel - 264_680uL), any()) } + @Test + fun `onConfirmAmount refuses to create an order the balance cannot fund`() = test { + val amount = 260_000uL + val budget = 265_000uL + val response = stubFeeResponse(6_000uL) // 260_000 + 6_000 is over the budget + 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.estimateOrderFee(eq(amount), any(), any())).thenReturn(Result.success(response)) + + sut.transferEffects.test { + sut.onConfirmAmount(amount.toLong()) + advanceUntilIdle() + + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + verify(blocktankRepo, never()).createOrder(any(), any(), any()) + assertFalse(sut.spendingUiState.value.isLoading) + } + + @Test + fun `onConfirmAmount creates the order when it fits the funding budget`() = 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(eq(amount), any(), any())).thenReturn(Result.success(response)) + whenever(blocktankRepo.createOrder(any(), any(), any())) + .thenReturn(Result.success(previewBtOrder(clientBalanceSat = amount))) + + sut.onConfirmAmount(amount.toLong()) + advanceUntilIdle() + + verify(blocktankRepo).createOrder(eq(amount), any(), any()) + } + + @Test + fun `onConfirmAmount proceeds when the on-chain balance cannot be read`() = test { + val amount = 260_000uL + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.failure(AppError("node unavailable"))) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) + whenever(blocktankRepo.createOrder(any(), any(), any())) + .thenReturn(Result.success(previewBtOrder(clientBalanceSat = amount))) + + sut.onConfirmAmount(amount.toLong()) + advanceUntilIdle() + + // an unreadable balance must not block the flow; confirm stays the authority + verify(blocktankRepo).createOrder(eq(amount), any(), any()) + } + + @Test + fun `updateLimits keeps the last candidate when a re-quote fails`() = test { + val spendable = 266_656uL + val miningFee = 178uL + val availableAmount = spendable - miningFee // 266_478 + stubSpendableBalances(spendable) + blocktankState.value = BlocktankState(info = null) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(miningFee)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = spendable))) + val first = stubFeeResponse(1_798uL) + val second = stubFeeResponse(1_800uL) + whenever(blocktankRepo.estimateOrderFee(eq(availableAmount), any(), any())) + .thenReturn(Result.success(first)) + whenever(blocktankRepo.estimateOrderFee(eq(264_680uL), any(), any())) + .thenReturn(Result.success(second)) + whenever(blocktankRepo.estimateOrderFee(eq(264_678uL), any(), any())) + .thenReturn(Result.failure(AppError("lsp unreachable"))) + + sut.updateLimits() + advanceUntilIdle() + + // the step-down candidate is still published rather than the unaffordable quoted balance + assertEquals(264_678L, sut.spendingUiState.value.maxAllowedToSend) + } + + @Test + fun `updateLimits falls back to the shortfall balance when rounds are exhausted`() = test { + val spendable = 266_656uL + val miningFee = 178uL + val availableAmount = spendable - miningFee // 266_478 + stubSpendableBalances(spendable) + blocktankState.value = BlocktankState(info = null) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(miningFee)) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = spendable))) + // every quote stays 1_800, so no candidate ever becomes affordable and both rounds are used + val flat = stubFeeResponse(1_800uL) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(flat)) + + sut.updateLimits() + advanceUntilIdle() + + assertEquals((availableAmount - 1_800uL).toLong(), sut.spendingUiState.value.maxAllowedToSend) + } + @Test fun `updateLimits uses percent fallback when fast mining fee estimate fails`() = test { val spendable = 100_000uL From b87788d4d434658bbebd20121a0bf786c536d471 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 25 Aug 2026 13:22:23 -0300 Subject: [PATCH 08/11] refactor: reuse funding budget logic --- .../to/bitkit/viewmodels/TransferViewModel.kt | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index d53c1cbbdb..ba94442437 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -615,17 +615,7 @@ class TransferViewModel @Inject constructor( awaitNodeRunning() - // Match iOS: start from raw spendable (not maxSendOnchainSats — that already reserved - // a default-tier send-all fee), then subtract exactly one fast mining fee. - val spendable = lightningRepo.getBalancesAsync().getOrNull()?.spendableOnchainBalanceSats - ?: 0uL - val miningFee = lightningRepo.estimateSendAllFee( - speed = TransactionSpeed.Fast, - ).getOrElse { - Logger.warn("Failed to estimate transfer mining fee reserve", it, context = TAG) - (spendable.toDouble() * Defaults.fallbackFeePercent).toULong() - } - val availableAmount = spendable.safe() - miningFee.safe() + val availableAmount = loadFundingBudget() ?: 0uL val initialLspFees = estimateInitialLspFees(availableAmount) if (initialLspFees == null) { @@ -751,7 +741,12 @@ class TransferViewModel @Inject constructor( return fallback } - /** Order cost the on-chain balance can fund, or null when the balance itself is unreadable. */ + /** + * Order cost the on-chain balance can fund, or null when the balance itself is unreadable. + * + * Matches iOS by starting from raw spendable rather than `maxSendOnchainSats`, which has already + * reserved a default-tier send-all fee, and subtracting exactly one fast mining fee. + */ private suspend fun loadFundingBudget(): ULong? { val spendable = lightningRepo.getBalancesAsync().getOrNull()?.spendableOnchainBalanceSats ?: return null val miningFee = lightningRepo.estimateSendAllFee(speed = TransactionSpeed.Fast).getOrElse { From 3a8ec380d0ed0d44c2f1294043c81df7edcb49e9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 25 Aug 2026 14:20:05 -0300 Subject: [PATCH 09/11] fix: update _spendingUiState with fundingBudgetSats --- .../to/bitkit/viewmodels/TransferViewModel.kt | 19 ++++++--- .../viewmodels/TransferViewModelTest.kt | 42 ++++++++++++++++++- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index ba94442437..3a4187a1d7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -615,7 +615,9 @@ class TransferViewModel @Inject constructor( awaitNodeRunning() - val availableAmount = loadFundingBudget() ?: 0uL + val fundingBudget = loadFundingBudget() + _spendingUiState.update { it.copy(fundingBudgetSats = fundingBudget) } + val availableAmount = fundingBudget ?: 0uL val initialLspFees = estimateInitialLspFees(availableAmount) if (initialLspFees == null) { @@ -760,14 +762,16 @@ class TransferViewModel @Inject constructor( * 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, and the balance can move after it was sized, so the order is checked - * against a live quote before it is placed. An unknown budget or quote leaves the decision to - * the confirm step rather than blocking the user here. + * 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. */ private suspend fun canFundOrder(clientBalance: ULong): Boolean { - val budget = loadFundingBudget() + val budget = _spendingUiState.value.fundingBudgetSats if (budget == null) { - Logger.warn("Skipped funding check, on-chain balance unavailable", context = TAG) + Logger.warn("Skipped funding check, no sized budget available", context = TAG) return true } val fee = quoteOrderFee(clientBalance) @@ -855,6 +859,7 @@ class TransferViewModel @Inject constructor( updateTransferValues(0uL) val availableAmount = account.balanceSats.safe() - hwFundingFeeReserve(account.balanceSats).safe() + _spendingUiState.update { it.copy(fundingBudgetSats = availableAmount) } val initialLspFees = estimateInitialLspFees(availableAmount) if (initialLspFees == null) { @@ -1750,6 +1755,8 @@ data class TransferToSpendingUiState( val shouldUseSendAll: Boolean = false, val receivingAmount: Long = 0, val feeEstimate: Long? = null, + /** Budget the transfer limits were sized against, or null while unknown. */ + val fundingBudgetSats: ULong? = 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 0b69dd755b..1fc44c7850 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -350,7 +350,9 @@ class TransferViewModelTest : BaseUnitTest() { .thenReturn(Result.success(0uL)) whenever(blocktankRepo.calculateLiquidityOptions(any())) .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(eq(amount), any(), any())).thenReturn(Result.success(response)) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) + sut.updateLimits() + advanceUntilIdle() sut.transferEffects.test { sut.onConfirmAmount(amount.toLong()) @@ -372,9 +374,11 @@ class TransferViewModelTest : BaseUnitTest() { .thenReturn(Result.success(0uL)) whenever(blocktankRepo.calculateLiquidityOptions(any())) .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) - whenever(blocktankRepo.estimateOrderFee(eq(amount), any(), any())).thenReturn(Result.success(response)) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(response)) whenever(blocktankRepo.createOrder(any(), any(), any())) .thenReturn(Result.success(previewBtOrder(clientBalanceSat = amount))) + sut.updateLimits() + advanceUntilIdle() sut.onConfirmAmount(amount.toLong()) advanceUntilIdle() @@ -390,6 +394,9 @@ class TransferViewModelTest : BaseUnitTest() { .thenReturn(Result.success(liquidityOptionsForCreate(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) whenever(blocktankRepo.createOrder(any(), any(), any())) .thenReturn(Result.success(previewBtOrder(clientBalanceSat = amount))) + sut.updateLimits() + advanceUntilIdle() + assertNull(sut.spendingUiState.value.fundingBudgetSats) sut.onConfirmAmount(amount.toLong()) advanceUntilIdle() @@ -491,6 +498,37 @@ class TransferViewModelTest : BaseUnitTest() { assertEquals(OPTION_MAX_CLIENT_BALANCE.toLong(), sut.spendingUiState.value.maxAllowedToSend) } + @Test + fun `onConfirmAmount funds a hardware transfer from the device balance not the on-chain wallet`() = test { + // Regression: the funding check must not read on-chain savings here, or every hardware + // transfer is rejected because those funds live on the device. + val amount = 100_000uL + stubSpendableBalances(0uL) // empty on-chain wallet, as in the hardware e2e + blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) + whenever(hwWalletRepo.getFundingAccount(HARDWARE_WALLET_ID)).thenReturn( + Result.success( + HwFundingAccount.Trezor( + xpub = XPUB, + addressType = HwFundingAddressType.NATIVE_SEGWIT, + 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.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) + whenever(blocktankRepo.createOrder(any(), any(), any())) + .thenReturn(Result.success(previewBtOrder(clientBalanceSat = amount))) + sut.updateHwLimits(HARDWARE_WALLET_ID) + advanceUntilIdle() + + sut.onConfirmAmount(amount.toLong()) + advanceUntilIdle() + + verify(blocktankRepo).createOrder(eq(amount), any(), any()) + } + @Test fun `updateHwLimits reserves fallback fee when fee rate lookup fails`() = test { blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) From c0fe35ae0f03127a03d563d1d1ffd25f6e5439a3 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 26 Aug 2026 08:17:57 -0300 Subject: [PATCH 10/11] test: update the test to check the loop and quotes --- .../bitkit/viewmodels/TransferViewModelTest.kt | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 1fc44c7850..b559093df6 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -437,20 +437,30 @@ class TransferViewModelTest : BaseUnitTest() { val spendable = 266_656uL val miningFee = 178uL val availableAmount = spendable - miningFee // 266_478 + // the fee rises as fast as the balance steps down, so no candidate ever becomes affordable + val quotes = mapOf( + 266_478uL to 1_800uL, // f(A) -> balanceAfterLspFee = 264_678 + 264_678uL to 2_000uL, // f(C) -> 266_678, over budget + 264_478uL to 2_200uL, // round 1 -> 266_678, still over + 264_278uL to 2_400uL, // round 2 -> 266_678, rounds exhausted + ) stubSpendableBalances(spendable) blocktankState.value = BlocktankState(info = null) whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } .thenReturn(Result.success(miningFee)) whenever(blocktankRepo.calculateLiquidityOptions(any())) .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = spendable))) - // every quote stays 1_800, so no candidate ever becomes affordable and both rounds are used - val flat = stubFeeResponse(1_800uL) - whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(flat)) + val responses = quotes.mapValues { (_, fee) -> stubFeeResponse(fee) } + responses.forEach { (balance, response) -> + whenever(blocktankRepo.estimateOrderFee(eq(balance), any(), any())) + .thenReturn(Result.success(response)) + } sut.updateLimits() advanceUntilIdle() - assertEquals((availableAmount - 1_800uL).toLong(), sut.spendingUiState.value.maxAllowedToSend) + // the exhausted loop advertises availableAmount minus the last quote, not the last candidate + assertEquals((availableAmount - 2_400uL).toLong(), sut.spendingUiState.value.maxAllowedToSend) } @Test From 83a5530901fdaf487a8f5c405ac2ae4c89db6ba6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 26 Aug 2026 08:20:35 -0300 Subject: [PATCH 11/11] test: cover confirm skip on failed fee quote --- .../viewmodels/TransferViewModelTest.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index b559093df6..b90b64d428 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -405,6 +405,34 @@ class TransferViewModelTest : BaseUnitTest() { verify(blocktankRepo).createOrder(eq(amount), any(), any()) } + @Test + fun `onConfirmAmount proceeds when the confirm-time fee estimate fails`() = 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)) + whenever(blocktankRepo.createOrder(any(), any(), any())) + .thenReturn(Result.success(previewBtOrder(clientBalanceSat = amount))) + sut.updateLimits() + advanceUntilIdle() + // the budget is sized, so this is the failed-quote path rather than the unset-budget one + assertNotNull(sut.spendingUiState.value.fundingBudgetSats) + + // the LSP stops quoting only after the limits were sized + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())) + .thenReturn(Result.failure(AppError("lsp unreachable"))) + + sut.onConfirmAmount(amount.toLong()) + advanceUntilIdle() + + // a quote the LSP will not give must not block the user; confirm stays the authority + verify(blocktankRepo).createOrder(eq(amount), any(), any()) + } + @Test fun `updateLimits keeps the last candidate when a re-quote fails`() = test { val spendable = 266_656uL