diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 5333266eca..3a4187a1d7 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 @@ -150,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, @@ -602,17 +615,9 @@ 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 fundingBudget = loadFundingBudget() + _spendingUiState.update { it.copy(fundingBudgetSats = fundingBudget) } + val availableAmount = fundingBudget ?: 0uL val initialLspFees = estimateInitialLspFees(availableAmount) if (initialLspFees == null) { @@ -673,9 +678,12 @@ class TransferViewModel @Inject constructor( spendingBalanceSats = cappedClientBalance, receivingBalanceSats = receivingAmount, ).onSuccess { estimate -> - maxLspFee = estimate.feeSat val lspFees = estimate.networkFeeSat.safe() + estimate.serviceFeeSat.safe() - val maxClientBalance = availableAmount.safe() - lspFees.safe() + val maxClientBalance = resolveAffordableClientBalance( + availableAmount = availableAmount, + quotedBalance = cappedClientBalance, + quotedFee = lspFees, + ) val maxSend = min( liquidity.maxClientBalanceSat.toLong(), maxClientBalance.toLong() @@ -697,6 +705,96 @@ 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, + 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 = 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 + } + + /** + * 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 { + 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, 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 = _spendingUiState.value.fundingBudgetSats + if (budget == null) { + Logger.warn("Skipped funding check, no sized budget available", 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. + */ + 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() { val defaultOrder = _spendingUiState.value.defaultOrder hwFeeEstimateJob?.cancel() @@ -761,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) { @@ -1578,6 +1677,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 @@ -1653,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/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 a1f0f7d56c..b90b64d428 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 @@ -233,6 +235,262 @@ 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 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 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 `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(any(), any(), any())).thenReturn(Result.success(response)) + sut.updateLimits() + advanceUntilIdle() + + 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(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() + + 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.updateLimits() + advanceUntilIdle() + assertNull(sut.spendingUiState.value.fundingBudgetSats) + + 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 `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 + 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 + // 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))) + 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() + + // the exhausted loop advertises availableAmount minus the last quote, not the last candidate + assertEquals((availableAmount - 2_400uL).toLong(), sut.spendingUiState.value.maxAllowedToSend) + } + @Test fun `updateLimits uses percent fallback when fast mining fee estimate fails`() = test { val spendable = 100_000uL @@ -278,6 +536,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)) @@ -1702,6 +1991,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/1179.fixed.md b/changelog.d/next/1179.fixed.md new file mode 100644 index 0000000000..06c14fef3d --- /dev/null +++ b/changelog.d/next/1179.fixed.md @@ -0,0 +1 @@ +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.