From ea596e341932cc95e6b1ea2b13d14cd6fc590865 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 17 Jul 2026 12:19:49 +0200 Subject: [PATCH 1/7] fix: use fast fee for savings transfer --- .../to/bitkit/viewmodels/TransferViewModel.kt | 64 ++++--- .../viewmodels/TransferViewModelTest.kt | 159 ++++++++++++++++++ changelog.d/next/1034.fixed.md | 1 + 3 files changed, 201 insertions(+), 23 deletions(-) create mode 100644 changelog.d/next/1034.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 e66ccaf7e..1127acef5 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -212,7 +212,8 @@ class TransferViewModel @Inject constructor( } /** Pays for the order and start watching it for state updates */ - fun onTransferToSpendingConfirm(order: IBtOrder, speed: TransactionSpeed? = null) { + @Suppress("LongMethod") + fun onTransferToSpendingConfirm(order: IBtOrder, speed: TransactionSpeed = TransactionSpeed.Fast) { viewModelScope.launch { val address = order.payment?.onchain?.address.orEmpty() @@ -230,26 +231,12 @@ class TransferViewModel @Inject constructor( val expectedChange = spendableBalance.toLong() - order.feeSat.toLong() - sendAllFee.toLong() + // Match iOS: proactive drain only for dust change. Negative change means the drain + // output would underpay the order — never send-all in that case. val shouldUseSendAll = expectedChange >= 0 && expectedChange < TRANSFER_SEND_ALL_THRESHOLD_SATS - - val miningFee = if (shouldUseSendAll) { - sendAllFee - } else { - lightningRepo.calculateTotalFee( - amountSats = order.feeSat, - address = address, - speed = speed, - ).getOrElse { - Logger.warn("Failed to estimate transfer funding fee", it, context = TAG) - 0uL - } - } - val txTotalSats = if (shouldUseSendAll) { - spendableBalance - } else { - order.feeSat.safe() + miningFee.safe() - } + val maxSendable = + if (sendAllFee >= spendableBalance) 0uL else spendableBalance - sendAllFee Logger.debug( "BT confirm: spendable=$spendableBalance, feeSat=${order.feeSat}, " + @@ -257,26 +244,57 @@ class TransferViewModel @Inject constructor( context = TAG, ) - lightningRepo + suspend fun fund(isMaxAmount: Boolean, txTotalSats: ULong) = lightningRepo .sendOnChain( address = address, sats = order.feeSat, speed = speed, isTransfer = true, channelId = order.channel?.shortChannelId, - isMaxAmount = shouldUseSendAll, + isMaxAmount = isMaxAmount, ) .onSuccess { txId -> fundPaidOrder( order = order, txId = txId, txTotalSats = txTotalSats, - preTransferOnchainSats = balanceDetails?.totalOnchainBalanceSats ?: spendableBalance, + preTransferOnchainSats = balanceDetails?.totalOnchainBalanceSats + ?: spendableBalance, ) } - .onFailure { error -> + + if (shouldUseSendAll) { + fund(isMaxAmount = true, txTotalSats = spendableBalance) + .onFailure { ToastEventBus.send(it) } + return@launch + } + + val miningFee = lightningRepo.calculateTotalFee( + amountSats = order.feeSat, + address = address, + speed = speed, + ).getOrElse { + Logger.warn("Failed to estimate transfer funding fee", it, context = TAG) + 0uL + } + fund( + isMaxAmount = false, + txTotalSats = order.feeSat.safe() + miningFee.safe(), + ).onFailure { error -> + // Match iOS: if fixed send fails (e.g. coin selection), drain only when the + // drain output still covers order.feeSat — never underpay by emptying the wallet. + if (maxSendable >= order.feeSat) { + Logger.warn( + "Normal transfer funding failed, retrying send-all", + error, + context = TAG, + ) + fund(isMaxAmount = true, txTotalSats = spendableBalance) + .onFailure { ToastEventBus.send(it) } + } else { ToastEventBus.send(error) } + } } } diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index ccd100f41..63164a320 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.test.setMain import kotlinx.coroutines.withTimeout import org.junit.Before import org.junit.Test +import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.NodeStatus import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull @@ -300,6 +301,136 @@ class TransferViewModelTest : BaseUnitTest() { assertEquals(999uL, sut.spendingUiState.value.hwMiningFeeSats) } + @Test + fun `onTransferToSpendingConfirm uses send-all when expected change is dust`() = test { + val order = previewBtOrder(feeSat = 99_000uL) + stubSpendableBalances(spendable = 100_000u) + whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())).thenReturn(Result.success(500uL)) + stubSendOnChainSuccess() + + sut.onTransferToSpendingConfirm(order) + advanceUntilIdle() + + verify(lightningRepo).sendOnChain( + address = eq(order.payment?.onchain?.address.orEmpty()), + sats = eq(order.feeSat), + speed = eq(TransactionSpeed.Fast), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = eq(true), + channelId = anyOrNull(), + isMaxAmount = eq(true), + tags = any(), + ) + verify(lightningRepo, never()).calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull()) + verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) + } + + @Test + fun `onTransferToSpendingConfirm retries send-all when fixed send fails but drain still covers order`() = + test { + val order = previewBtOrder(feeSat = 98_000uL) + stubSpendableBalances(spendable = 100_000u) + whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + whenever( + lightningRepo.sendOnChain( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + any(), + anyOrNull(), + any(), + any(), + ), + ).thenReturn( + Result.failure(AppError("Coin selection failed")), + Result.success(TXID), + ) + + sut.onTransferToSpendingConfirm(order) + advanceUntilIdle() + + verify(lightningRepo).sendOnChain( + address = eq(order.payment?.onchain?.address.orEmpty()), + sats = eq(order.feeSat), + speed = eq(TransactionSpeed.Fast), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = eq(true), + channelId = anyOrNull(), + isMaxAmount = eq(false), + tags = any(), + ) + verify(lightningRepo).sendOnChain( + address = eq(order.payment?.onchain?.address.orEmpty()), + sats = eq(order.feeSat), + speed = eq(TransactionSpeed.Fast), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = eq(true), + channelId = anyOrNull(), + isMaxAmount = eq(true), + tags = any(), + ) + verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) + } + + @Test + fun `onTransferToSpendingConfirm does not drain when send-all would underpay the order`() = test { + // CI multi_address_2 numbers: drain fee leaves maxSendable < feeSat. + val order = previewBtOrder(feeSat = 99_457uL) + stubSpendableBalances(spendable = 100_000u) + whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())) + .thenReturn(Result.success(1_058uL)) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + whenever( + lightningRepo.sendOnChain( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + any(), + anyOrNull(), + any(), + any(), + ), + ).thenReturn(Result.failure(AppError("Coin selection failed"))) + + sut.onTransferToSpendingConfirm(order) + advanceUntilIdle() + + verify(lightningRepo, times(1)).sendOnChain( + address = eq(order.payment?.onchain?.address.orEmpty()), + sats = eq(order.feeSat), + speed = eq(TransactionSpeed.Fast), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = eq(true), + channelId = anyOrNull(), + isMaxAmount = eq(false), + tags = any(), + ) + verify(lightningRepo, never()).sendOnChain( + address = any(), + sats = any(), + speed = any(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = eq(true), + tags = any(), + ) + verify(cacheStore, never()).addPaidOrder(any(), any()) + } + @Test fun `onTransferToSpendingHwConfirm signs the funding send and records the paid order`() = test { val order = previewBtOrder() @@ -1004,6 +1135,34 @@ class TransferViewModelTest : BaseUnitTest() { return mock().also { whenever(it.options).thenReturn(options) } } + private suspend fun stubSpendableBalances(spendable: ULong) { + val balances = BalanceDetails( + totalOnchainBalanceSats = spendable, + spendableOnchainBalanceSats = spendable, + totalAnchorChannelsReserveSats = 0u, + totalLightningBalanceSats = 0u, + lightningBalances = emptyList(), + pendingBalancesFromChannelClosures = emptyList(), + ) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) + } + + private suspend fun stubSendOnChainSuccess() { + whenever( + lightningRepo.sendOnChain( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + any(), + anyOrNull(), + any(), + any(), + ), + ).thenReturn(Result.success(TXID)) + } + private companion object { const val ON_CHAIN_BALANCE = 10_000_000uL const val LSP_MAX_CLIENT_BALANCE = 1_766_193uL diff --git a/changelog.d/next/1034.fixed.md b/changelog.d/next/1034.fixed.md new file mode 100644 index 000000000..d03e144b9 --- /dev/null +++ b/changelog.d/next/1034.fixed.md @@ -0,0 +1 @@ +Savings transfers to spending now use a faster on-chain fee rate, and only drain the wallet when leftover change would be dust or a retry can still fully fund the order. From c78b2131178dad17b5308abda7ada610f7739c36 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 17 Jul 2026 12:28:38 +0200 Subject: [PATCH 2/7] fix: reserve mining fee for max transfer --- .../to/bitkit/viewmodels/TransferViewModel.kt | 14 +++++++-- .../viewmodels/TransferViewModelTest.kt | 31 +++++++++++++++++++ changelog.d/next/1034.fixed.md | 2 +- 3 files changed, 44 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 1127acef5..36440e4a2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -433,10 +433,20 @@ class TransferViewModel @Inject constructor( viewModelScope.launch { _spendingUiState.update { it.copy(isLoading = true) } - val availableAmount = walletRepo.balanceState.value.maxSendOnchainSats - awaitNodeRunning() + // Match iOS: size the order against spendable minus fast send-all mining fee so + // max transfer leaves room to fund the order on-chain. + val spendable = walletRepo.balanceState.value.maxSendOnchainSats + val miningFee = lightningRepo.estimateSendAllFee( + speed = TransactionSpeed.Fast, + ).getOrElse { + Logger.warn("Failed to estimate transfer mining fee reserve", it, context = TAG) + 0uL + } + val availableAmount = + if (miningFee >= spendable) 0uL else spendable - miningFee + val initialLspFees = estimateInitialLspFees(availableAmount) if (initialLspFees == null) { _spendingUiState.update { it.copy(isLoading = false) } diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 63164a320..afb790174 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -52,6 +52,7 @@ import to.bitkit.models.HwFundingSignedTx import to.bitkit.models.HwFundingTransaction import to.bitkit.models.HwWallet import to.bitkit.models.Toast +import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType import to.bitkit.models.TransportType import to.bitkit.models.safe @@ -104,6 +105,9 @@ class TransferViewModelTest : BaseUnitTest() { whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(nodeStatus = nodeStatus))) whenever(walletRepo.balanceState).thenReturn(balanceState) whenever(blocktankRepo.blocktankState).thenReturn(blocktankState) + // Default: no mining-fee reserve so existing limit tests keep their balances. + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.success(0uL)) sut = TransferViewModel( context = context, @@ -170,6 +174,33 @@ class TransferViewModelTest : BaseUnitTest() { assertEquals(0L, sut.spendingUiState.value.maxAllowedToSend) } + @Test + fun `updateLimits reserves fast mining fee before sizing max transfer`() = test { + // multi_address_2-style tight balance: without this reserve, feeSat + miningFee > spendable. + val spendable = 100_000uL + val miningFee = 1_058uL + val availableAfterMining = spendable - miningFee + balanceState.value = BalanceState(maxSendOnchainSats = 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(any(), any(), any())).thenReturn(Result.success(feeResponse)) + + sut.updateLimits() + advanceUntilIdle() + + val expectedMax = (availableAfterMining - LSP_FEE).toLong() + assertEquals(expectedMax, sut.spendingUiState.value.maxAllowedToSend) + verify(lightningRepo).estimateSendAllFee( + address = anyOrNull(), + speed = eq(TransactionSpeed.Fast), + feeRates = anyOrNull(), + ) + verify(blocktankRepo).estimateOrderFee(eq(availableAfterMining), any(), any()) + } + @Test fun `updateHwLimits sources the available amount from the hardware account balance`() = test { // walletRepo balance stays 0 to prove the limit comes from the hardware account, not on-chain savings. diff --git a/changelog.d/next/1034.fixed.md b/changelog.d/next/1034.fixed.md index d03e144b9..cf3245d66 100644 --- a/changelog.d/next/1034.fixed.md +++ b/changelog.d/next/1034.fixed.md @@ -1 +1 @@ -Savings transfers to spending now use a faster on-chain fee rate, and only drain the wallet when leftover change would be dust or a retry can still fully fund the order. +Savings transfers to spending now use a faster on-chain fee rate, reserve mining fees when sizing max transfers, and only drain the wallet when leftover change would be dust or a retry can still fully fund the order. From 55fffe49663d10b1a6348b48ef6d493d563d8371 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 17 Jul 2026 12:32:38 +0200 Subject: [PATCH 3/7] chore: rename changelog fragment Co-authored-by: Cursor --- changelog.d/next/{1034.fixed.md => 1091.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{1034.fixed.md => 1091.fixed.md} (100%) diff --git a/changelog.d/next/1034.fixed.md b/changelog.d/next/1091.fixed.md similarity index 100% rename from changelog.d/next/1034.fixed.md rename to changelog.d/next/1091.fixed.md From 35b39c7bba9ad1c63d6120337601a3c7a52dc98c Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Fri, 17 Jul 2026 12:36:56 +0200 Subject: [PATCH 4/7] fix: avoid double mining fee on max transfer Co-authored-by: Cursor --- .../to/bitkit/viewmodels/TransferViewModel.kt | 9 +++--- .../viewmodels/TransferViewModelTest.kt | 31 ++++++++++++++++--- 2 files changed, 32 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 36440e4a2..a9f8d6b79 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -435,14 +435,15 @@ class TransferViewModel @Inject constructor( awaitNodeRunning() - // Match iOS: size the order against spendable minus fast send-all mining fee so - // max transfer leaves room to fund the order on-chain. - val spendable = walletRepo.balanceState.value.maxSendOnchainSats + // 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) - 0uL + (spendable.toDouble() * Defaults.fallbackFeePercent).toULong() } val availableAmount = if (miningFee >= spendable) 0uL else spendable - miningFee diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index afb790174..9995ba9a3 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -124,7 +124,7 @@ class TransferViewModelTest : BaseUnitTest() { @Test fun `updateLimits caps spending max at LSP max client balance when on-chain balance exceeds it`() = test { - balanceState.value = BalanceState(maxSendOnchainSats = ON_CHAIN_BALANCE) + stubSpendableBalances(ON_CHAIN_BALANCE) blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) // The LSP reports no room for receiving liquidity (maxLspBalanceSat = 0) because the // client balance saturates the channel — the regression this guards against. @@ -146,7 +146,7 @@ class TransferViewModelTest : BaseUnitTest() { @Test fun `updateLimits uses the full balance when LSP info is unavailable`() = test { - balanceState.value = BalanceState(maxSendOnchainSats = ON_CHAIN_BALANCE) + stubSpendableBalances(ON_CHAIN_BALANCE) blocktankState.value = BlocktankState(info = null) whenever(blocktankRepo.calculateLiquidityOptions(any())) .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = OPTION_MAX_CLIENT_BALANCE))) @@ -162,7 +162,7 @@ class TransferViewModelTest : BaseUnitTest() { @Test fun `updateLimits sets max to zero when LSP reports zero client balance`() = test { - balanceState.value = BalanceState(maxSendOnchainSats = ON_CHAIN_BALANCE) + stubSpendableBalances(ON_CHAIN_BALANCE) blocktankState.value = BlocktankState(info = btInfo(lspMaxClientBalance = LSP_MAX_CLIENT_BALANCE)) whenever(blocktankRepo.calculateLiquidityOptions(any())) .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = 0uL))) @@ -180,7 +180,10 @@ class TransferViewModelTest : BaseUnitTest() { val spendable = 100_000uL val miningFee = 1_058uL val availableAfterMining = spendable - miningFee - balanceState.value = BalanceState(maxSendOnchainSats = spendable) + // maxSendOnchainSats is already fee-adjusted for send UI — limits must ignore it and + // reserve exactly one fast fee from raw spendable (not double-subtract). + balanceState.value = BalanceState(maxSendOnchainSats = spendable - miningFee) + stubSpendableBalances(spendable) blocktankState.value = BlocktankState(info = null) whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } .thenReturn(Result.success(miningFee)) @@ -201,6 +204,26 @@ class TransferViewModelTest : BaseUnitTest() { verify(blocktankRepo).estimateOrderFee(eq(availableAfterMining), any(), any()) } + @Test + fun `updateLimits uses percent fallback when fast mining fee estimate fails`() = test { + val spendable = 100_000uL + val fallbackMiningFee = (spendable.toDouble() * Defaults.fallbackFeePercent).toULong() + val availableAfterMining = spendable - fallbackMiningFee + stubSpendableBalances(spendable) + blocktankState.value = BlocktankState(info = null) + whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } + .thenReturn(Result.failure(AppError("fee unavailable"))) + whenever(blocktankRepo.calculateLiquidityOptions(any())) + .thenReturn(Result.success(liquidityOptions(maxClientBalanceSat = spendable))) + whenever(blocktankRepo.estimateOrderFee(any(), any(), any())).thenReturn(Result.success(feeResponse)) + + sut.updateLimits() + advanceUntilIdle() + + assertEquals((availableAfterMining - LSP_FEE).toLong(), sut.spendingUiState.value.maxAllowedToSend) + verify(blocktankRepo).estimateOrderFee(eq(availableAfterMining), any(), any()) + } + @Test fun `updateHwLimits sources the available amount from the hardware account balance`() = test { // walletRepo balance stays 0 to prove the limit comes from the hardware account, not on-chain savings. From 79eec29d23223db27b5b13cf91849672ec132012 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Mon, 20 Jul 2026 12:23:45 +0200 Subject: [PATCH 5/7] refactor: tidy savings transfer confirm api --- .../java/to/bitkit/viewmodels/TransferViewModel.kt | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index a9f8d6b79..821d49936 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -213,9 +213,10 @@ class TransferViewModel @Inject constructor( /** Pays for the order and start watching it for state updates */ @Suppress("LongMethod") - fun onTransferToSpendingConfirm(order: IBtOrder, speed: TransactionSpeed = TransactionSpeed.Fast) { + fun onTransferToSpendingConfirm(order: IBtOrder) { viewModelScope.launch { val address = order.payment?.onchain?.address.orEmpty() + val speed = TransactionSpeed.Fast // Use live spendableOnchainBalanceSats (not cached) to respect anchor reserves val balanceDetails = lightningRepo.getBalancesAsync().getOrNull() @@ -233,10 +234,8 @@ class TransferViewModel @Inject constructor( spendableBalance.toLong() - order.feeSat.toLong() - sendAllFee.toLong() // Match iOS: proactive drain only for dust change. Negative change means the drain // output would underpay the order — never send-all in that case. - val shouldUseSendAll = - expectedChange >= 0 && expectedChange < TRANSFER_SEND_ALL_THRESHOLD_SATS - val maxSendable = - if (sendAllFee >= spendableBalance) 0uL else spendableBalance - sendAllFee + val shouldUseSendAll = expectedChange in 0 until TRANSFER_SEND_ALL_THRESHOLD_SATS + val maxSendable = spendableBalance.safe() - sendAllFee.safe() Logger.debug( "BT confirm: spendable=$spendableBalance, feeSat=${order.feeSat}, " + @@ -445,8 +444,7 @@ class TransferViewModel @Inject constructor( Logger.warn("Failed to estimate transfer mining fee reserve", it, context = TAG) (spendable.toDouble() * Defaults.fallbackFeePercent).toULong() } - val availableAmount = - if (miningFee >= spendable) 0uL else spendable - miningFee + val availableAmount = spendable.safe() - miningFee.safe() val initialLspFees = estimateInitialLspFees(availableAmount) if (initialLspFees == null) { From 231e9b883817c1b33b1f07f2256a9997a6d34497 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Mon, 20 Jul 2026 14:04:21 +0200 Subject: [PATCH 6/7] fix: drain transfer only on real dust --- .../to/bitkit/repositories/LightningRepo.kt | 15 ++ .../to/bitkit/viewmodels/TransferViewModel.kt | 108 +++++++++----- .../viewmodels/TransferViewModelTest.kt | 134 ++++++++++-------- 3 files changed, 166 insertions(+), 91 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 1e8800355..28d120995 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -46,6 +46,7 @@ import org.lightningdevkit.ldknode.ChannelConfig import org.lightningdevkit.ldknode.ChannelDataMigration import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.ClosureReason +import org.lightningdevkit.ldknode.CoinSelectionAlgorithm import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.NodeStatus import org.lightningdevkit.ldknode.PaymentDetails @@ -1262,6 +1263,20 @@ class LightningRepo @Inject constructor( lightningService.listSpendableOutputs() } + suspend fun selectUtxosWithAlgorithm( + targetAmountSats: ULong, + satsPerVByte: ULong, + algorithm: CoinSelectionAlgorithm = CoinSelectionAlgorithm.LARGEST_FIRST, + utxos: List? = null, + ): Result> = executeWhenNodeRunning("selectUtxosWithAlgorithm") { + lightningService.selectUtxosWithAlgorithm( + targetAmountSats = targetAmountSats, + satsPerVByte = satsPerVByte, + algorithm = algorithm, + utxos = utxos, + ) + } + suspend fun calculateTotalFee( amountSats: ULong, address: Address? = null, diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 821d49936..96683dbbc 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -28,6 +28,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.CoinSelectionAlgorithm +import org.lightningdevkit.ldknode.SpendableUtxo import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore @@ -212,7 +214,7 @@ class TransferViewModel @Inject constructor( } /** Pays for the order and start watching it for state updates */ - @Suppress("LongMethod") + @Suppress("LongMethod", "CyclomaticComplexMethod") fun onTransferToSpendingConfirm(order: IBtOrder) { viewModelScope.launch { val address = order.payment?.onchain?.address.orEmpty() @@ -229,25 +231,63 @@ class TransferViewModel @Inject constructor( ToastEventBus.send(it) return@launch } - - val expectedChange = - spendableBalance.toLong() - order.feeSat.toLong() - sendAllFee.toLong() - // Match iOS: proactive drain only for dust change. Negative change means the drain - // output would underpay the order — never send-all in that case. - val shouldUseSendAll = expectedChange in 0 until TRANSFER_SEND_ALL_THRESHOLD_SATS val maxSendable = spendableBalance.safe() - sendAllFee.safe() + // Match iOS SpendingConfirm: try normal coin selection + fee first; drain only for + // real dust change on the selected inputs (never use send-all fee for this check). + var shouldUseSendAll = false + var selectedUtxos: List? = null + var normalFee = 0uL + + val satsPerVByte = lightningRepo.getFeeRateForSpeed(speed).getOrElse { + Logger.error("Failed to get transfer fee rate", it, context = TAG) + ToastEventBus.send(it) + return@launch + } + + lightningRepo.selectUtxosWithAlgorithm( + targetAmountSats = order.feeSat, + satsPerVByte = satsPerVByte, + algorithm = CoinSelectionAlgorithm.LARGEST_FIRST, + ).onSuccess { utxos -> + normalFee = lightningRepo.calculateTotalFee( + amountSats = order.feeSat, + address = address, + speed = speed, + utxosToSpend = utxos, + ).getOrElse { + Logger.warn("Failed to estimate transfer funding fee", it, context = TAG) + 0uL + } + selectedUtxos = utxos + val totalInput = utxos.fold(0uL) { acc, utxo -> acc.safe() + utxo.valueSats.safe() } + shouldUseSendAll = wouldCreateDustChange( + totalInput = totalInput, + amountSats = order.feeSat, + normalFee = normalFee, + ) + }.onFailure { + Logger.warn("Normal coin selection failed, using sendAll", it, context = TAG) + shouldUseSendAll = true + } + Logger.debug( "BT confirm: spendable=$spendableBalance, feeSat=${order.feeSat}, " + - "sendAllFee=$sendAllFee, expectedChange=$expectedChange, sendAll=$shouldUseSendAll", + "sendAllFee=$sendAllFee, normalFee=$normalFee, " + + "selectedUtxos=${selectedUtxos?.size}, sendAll=$shouldUseSendAll", context = TAG, ) - suspend fun fund(isMaxAmount: Boolean, txTotalSats: ULong) = lightningRepo + suspend fun fund( + isMaxAmount: Boolean, + txTotalSats: ULong, + utxosToSpend: List? = null, + ) = lightningRepo .sendOnChain( address = address, sats = order.feeSat, speed = speed, + utxosToSpend = utxosToSpend, isTransfer = true, channelId = order.channel?.shortChannelId, isMaxAmount = isMaxAmount, @@ -263,40 +303,41 @@ class TransferViewModel @Inject constructor( } if (shouldUseSendAll) { + if (maxSendable < order.feeSat) { + Logger.error( + "Insufficient balance for transfer: maxSendable=$maxSendable, " + + "orderFee=${order.feeSat}", + context = TAG, + ) + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__pay_insufficient_savings), + ) + return@launch + } fund(isMaxAmount = true, txTotalSats = spendableBalance) .onFailure { ToastEventBus.send(it) } return@launch } - val miningFee = lightningRepo.calculateTotalFee( - amountSats = order.feeSat, - address = address, - speed = speed, - ).getOrElse { - Logger.warn("Failed to estimate transfer funding fee", it, context = TAG) - 0uL - } fund( isMaxAmount = false, - txTotalSats = order.feeSat.safe() + miningFee.safe(), - ).onFailure { error -> - // Match iOS: if fixed send fails (e.g. coin selection), drain only when the - // drain output still covers order.feeSat — never underpay by emptying the wallet. - if (maxSendable >= order.feeSat) { - Logger.warn( - "Normal transfer funding failed, retrying send-all", - error, - context = TAG, - ) - fund(isMaxAmount = true, txTotalSats = spendableBalance) - .onFailure { ToastEventBus.send(it) } - } else { - ToastEventBus.send(error) - } - } + txTotalSats = order.feeSat.safe() + normalFee.safe(), + utxosToSpend = selectedUtxos, + ).onFailure { ToastEventBus.send(it) } } } + private fun wouldCreateDustChange( + totalInput: ULong, + amountSats: ULong, + normalFee: ULong, + dustLimit: ULong = Defaults.dustLimit.toULong(), + ): Boolean { + val expectedChange = totalInput.toLong() - amountSats.toLong() - normalFee.toLong() + return expectedChange in 0 until dustLimit.toLong() + } + /** Records a paid order and starts watching it, after the funding tx was broadcast (local or HW signed). */ private suspend fun fundPaidOrder( order: IBtOrder, @@ -1126,7 +1167,6 @@ class TransferViewModel @Inject constructor( companion object { private const val TAG = "TransferViewModel" - private const val TRANSFER_SEND_ALL_THRESHOLD_SATS = 1000 private const val MIN_STEP_DELAY_MS = 500L private const val POLL_INTERVAL_MS = 2_500L private const val MAX_CONSECUTIVE_ERRORS = 5 diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 9995ba9a3..c5536fa99 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -28,7 +28,10 @@ import kotlinx.coroutines.withTimeout import org.junit.Before import org.junit.Test import org.lightningdevkit.ldknode.BalanceDetails +import org.lightningdevkit.ldknode.CoinSelectionAlgorithm import org.lightningdevkit.ldknode.NodeStatus +import org.lightningdevkit.ldknode.OutPoint +import org.lightningdevkit.ldknode.SpendableUtxo import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doSuspendableAnswer @@ -108,6 +111,11 @@ class TransferViewModelTest : BaseUnitTest() { // Default: no mining-fee reserve so existing limit tests keep their balances. whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) } .thenReturn(Result.success(0uL)) + whenever { lightningRepo.getFeeRateForSpeed(any(), anyOrNull()) } + .thenReturn(Result.success(2uL)) + whenever { + lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) + }.thenReturn(Result.success(listOf(stubUtxo(ON_CHAIN_BALANCE)))) sut = TransferViewModel( context = context, @@ -356,15 +364,28 @@ class TransferViewModelTest : BaseUnitTest() { } @Test - fun `onTransferToSpendingConfirm uses send-all when expected change is dust`() = test { + fun `onTransferToSpendingConfirm uses send-all when selected inputs would create dust change`() = test { val order = previewBtOrder(feeSat = 99_000uL) + val selected = listOf(stubUtxo(100_000u)) stubSpendableBalances(spendable = 100_000u) whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())).thenReturn(Result.success(500uL)) + whenever { + lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) + }.thenReturn(Result.success(selected)) + // totalInput 100000 - feeSat 99000 - normalFee 500 = 500 dust (< Defaults.dustLimit) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(500uL)) stubSendOnChainSuccess() sut.onTransferToSpendingConfirm(order) advanceUntilIdle() + verify(lightningRepo).selectUtxosWithAlgorithm( + targetAmountSats = eq(order.feeSat), + satsPerVByte = any(), + algorithm = eq(CoinSelectionAlgorithm.LARGEST_FIRST), + utxos = anyOrNull(), + ) verify(lightningRepo).sendOnChain( address = eq(order.payment?.onchain?.address.orEmpty()), sats = eq(order.feeSat), @@ -376,71 +397,65 @@ class TransferViewModelTest : BaseUnitTest() { isMaxAmount = eq(true), tags = any(), ) - verify(lightningRepo, never()).calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull()) verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) } @Test - fun `onTransferToSpendingConfirm retries send-all when fixed send fails but drain still covers order`() = - test { - val order = previewBtOrder(feeSat = 98_000uL) - stubSpendableBalances(spendable = 100_000u) - whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())) - .thenReturn(Result.success(1_000uL)) - whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) - .thenReturn(Result.success(1_000uL)) - whenever( - lightningRepo.sendOnChain( - any(), - any(), - any(), - anyOrNull(), - anyOrNull(), - any(), - anyOrNull(), - any(), - any(), - ), - ).thenReturn( - Result.failure(AppError("Coin selection failed")), - Result.success(TXID), - ) + fun `onTransferToSpendingConfirm does not drain when normal fee leaves non-dust change`() = test { + // Regression: 41x1k UTXOs — send-all fee made expectedChange look like 0, but normal + // coin selection fee leaves real change and must not wipe the wallet. + val order = previewBtOrder(feeSat = 35_341uL) + val selected = listOf(stubUtxo(41_000u)) + stubSpendableBalances(spendable = 41_000u) + whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())) + .thenReturn(Result.success(5_659uL)) + whenever { + lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) + }.thenReturn(Result.success(selected)) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(2_830uL)) + stubSendOnChainSuccess() - sut.onTransferToSpendingConfirm(order) - advanceUntilIdle() + sut.onTransferToSpendingConfirm(order) + advanceUntilIdle() - verify(lightningRepo).sendOnChain( - address = eq(order.payment?.onchain?.address.orEmpty()), - sats = eq(order.feeSat), - speed = eq(TransactionSpeed.Fast), - utxosToSpend = anyOrNull(), - feeRates = anyOrNull(), - isTransfer = eq(true), - channelId = anyOrNull(), - isMaxAmount = eq(false), - tags = any(), - ) - verify(lightningRepo).sendOnChain( - address = eq(order.payment?.onchain?.address.orEmpty()), - sats = eq(order.feeSat), - speed = eq(TransactionSpeed.Fast), - utxosToSpend = anyOrNull(), - feeRates = anyOrNull(), - isTransfer = eq(true), - channelId = anyOrNull(), - isMaxAmount = eq(true), - tags = any(), - ) - verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) - } + verify(lightningRepo).sendOnChain( + address = eq(order.payment?.onchain?.address.orEmpty()), + sats = eq(order.feeSat), + speed = eq(TransactionSpeed.Fast), + utxosToSpend = eq(selected), + feeRates = anyOrNull(), + isTransfer = eq(true), + channelId = anyOrNull(), + isMaxAmount = eq(false), + tags = any(), + ) + verify(lightningRepo, never()).sendOnChain( + address = any(), + sats = any(), + speed = any(), + utxosToSpend = anyOrNull(), + feeRates = anyOrNull(), + isTransfer = any(), + channelId = anyOrNull(), + isMaxAmount = eq(true), + tags = any(), + ) + verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID)) + } @Test - fun `onTransferToSpendingConfirm does not drain when send-all would underpay the order`() = test { - // CI multi_address_2 numbers: drain fee leaves maxSendable < feeSat. - val order = previewBtOrder(feeSat = 99_457uL) + fun `onTransferToSpendingConfirm surfaces error when fixed send fails without draining`() = test { + // Match iOS: dust was already decided up front; do not surprise-drain on send failure. + val order = previewBtOrder(feeSat = 98_000uL) + val selected = listOf(stubUtxo(100_000u)) stubSpendableBalances(spendable = 100_000u) whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())) - .thenReturn(Result.success(1_058uL)) + .thenReturn(Result.success(1_000uL)) + whenever { + lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) + }.thenReturn(Result.success(selected)) + // 100000 - 98000 - 1000 = 1000, above dust → fixed send; drain would still cover order. whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) .thenReturn(Result.success(1_000uL)) whenever( @@ -464,7 +479,7 @@ class TransferViewModelTest : BaseUnitTest() { address = eq(order.payment?.onchain?.address.orEmpty()), sats = eq(order.feeSat), speed = eq(TransactionSpeed.Fast), - utxosToSpend = anyOrNull(), + utxosToSpend = eq(selected), feeRates = anyOrNull(), isTransfer = eq(true), channelId = anyOrNull(), @@ -1201,6 +1216,11 @@ class TransferViewModelTest : BaseUnitTest() { whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) } + private fun stubUtxo(valueSats: ULong): SpendableUtxo = SpendableUtxo( + outpoint = OutPoint(txid = "stub-utxo-txid", vout = 0u), + valueSats = valueSats, + ) + private suspend fun stubSendOnChainSuccess() { whenever( lightningRepo.sendOnChain( From f8be65b3545dfe3f8739d46f6962712804a5edb2 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Mon, 20 Jul 2026 15:46:07 +0200 Subject: [PATCH 7/7] fix: show real mining fee on confirm --- .../screens/transfer/SpendingConfirmScreen.kt | 59 ++-- .../to/bitkit/viewmodels/TransferViewModel.kt | 277 +++++++++++------- .../viewmodels/TransferViewModelTest.kt | 29 +- 3 files changed, 244 insertions(+), 121 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt index a543c7c37..6640f1e62 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -44,9 +45,9 @@ import com.synonym.bitkitcore.IBtOnchainTransactions import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.IBtPayment import com.synonym.bitkitcore.ILspNode -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import to.bitkit.R +import to.bitkit.models.safe import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.ChannelStatusUi import to.bitkit.ui.components.ConnectionIssuesView @@ -91,6 +92,12 @@ fun SpendingConfirmScreen( return } val isAdvanced = state.isAdvanced + val miningFeeSats = state.miningFeeSats + val isConfirmFeeReady = state.isConfirmFeeReady + + LaunchedEffect(order.id, order.feeSat) { + viewModel.prepareSpendingConfirmFunding(order) + } val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() @@ -116,6 +123,8 @@ fun SpendingConfirmScreen( onUseDefaultLspBalanceClick = viewModel::onUseDefaultLspBalanceClick, onTransferToSpendingConfirm = viewModel::onTransferToSpendingConfirm, order = order, + miningFeeSats = miningFeeSats, + isConfirmFeeReady = isConfirmFeeReady, hasNotificationPermission = notificationsGranted, onSwitchClick = onNotificationSwitchClick, isAdvanced = isAdvanced, @@ -133,7 +142,7 @@ fun SpendingConfirmScreen( } } -@Suppress("MagicNumber") +@Suppress("MagicNumber", "LongMethod") @Composable private fun Content( onBackClick: () -> Unit, @@ -143,8 +152,10 @@ private fun Content( onUseDefaultLspBalanceClick: () -> Unit, onSwitchClick: () -> Unit, hasNotificationPermission: Boolean, - onTransferToSpendingConfirm: (IBtOrder) -> Unit, + onTransferToSpendingConfirm: suspend (IBtOrder) -> Boolean, order: IBtOrder, + miningFeeSats: ULong, + isConfirmFeeReady: Boolean, isAdvanced: Boolean, ) { val scope = rememberCoroutineScope() @@ -175,10 +186,10 @@ private fun Content( .fillMaxSize() .verticalScroll(rememberScrollState()) ) { + // Match iOS SpendingConfirm: network fee = mining fee, lsp fee = order fee - client. val clientBalance = order.clientBalanceSat - val networkFee = order.networkFeeSat - val serviceFee = order.serviceFeeSat - val totalFee = order.feeSat + val lspFee = order.feeSat.safe() - clientBalance.safe() + val total = order.feeSat.safe() + miningFeeSats.safe() val lspBalance = order.lspBalanceSat VerticalSpacer(32.dp) @@ -191,11 +202,11 @@ private fun Content( ) { FeeInfo( label = stringResource(R.string.lightning__spending_confirm__network_fee), - amount = networkFee.toLong(), + amount = miningFeeSats.toLong(), ) FeeInfo( label = stringResource(R.string.lightning__spending_confirm__lsp_fee), - amount = serviceFee.toLong(), + amount = lspFee.toLong(), ) } Row( @@ -208,7 +219,7 @@ private fun Content( ) FeeInfo( label = stringResource(R.string.lightning__spending_confirm__total), - amount = totalFee.toLong(), + amount = total.toLong(), ) } @@ -268,16 +279,22 @@ private fun Content( FillHeight() var isLoading by remember { mutableStateOf(false) } + // Match iOS: keep swipe in loading state until mining fee is ready. + val canConfirm = isConfirmFeeReady && miningFeeSats > 0uL && !isLoading SwipeToConfirm( text = stringResource(R.string.lightning__transfer__swipe), - loading = isLoading, + loading = isLoading || !isConfirmFeeReady, color = Colors.Purple, onConfirm = { + if (!canConfirm) return@SwipeToConfirm scope.launch { isLoading = true - delay(300) - onTransferToSpendingConfirm(order) - onConfirm() + val paid = onTransferToSpendingConfirm(order) + if (paid) { + onConfirm() + } else { + isLoading = false + } } }, ) @@ -297,7 +314,7 @@ private fun Preview() { onAdvancedClick = {}, onConfirm = {}, onUseDefaultLspBalanceClick = {}, - onTransferToSpendingConfirm = {}, + onTransferToSpendingConfirm = { true }, order = IBtOrder( id = "order_7e6f3b7c-486a-4f5a-8b1e-2c9d7f0a8b9d", state = BtOrderState.CREATED, @@ -359,6 +376,8 @@ private fun Preview() { ), onSwitchClick = {}, hasNotificationPermission = true, + miningFeeSats = 250uL, + isConfirmFeeReady = true, isAdvanced = false ) } @@ -374,7 +393,7 @@ private fun Preview2() { onAdvancedClick = {}, onConfirm = {}, onUseDefaultLspBalanceClick = {}, - onTransferToSpendingConfirm = {}, + onTransferToSpendingConfirm = { true }, order = IBtOrder( id = "order_7e6f3b7c-486a-4f5a-8b1e-2c9d7f0a8b9d", state = BtOrderState.CREATED, @@ -436,6 +455,8 @@ private fun Preview2() { ), onSwitchClick = {}, hasNotificationPermission = true, + miningFeeSats = 250uL, + isConfirmFeeReady = true, isAdvanced = true ) } @@ -451,7 +472,7 @@ private fun Preview3() { onAdvancedClick = {}, onConfirm = {}, onUseDefaultLspBalanceClick = {}, - onTransferToSpendingConfirm = {}, + onTransferToSpendingConfirm = { true }, order = IBtOrder( id = "order_7e6f3b7c-486a-4f5a-8b1e-2c9d7f0a8b9d", state = BtOrderState.CREATED, @@ -513,6 +534,8 @@ private fun Preview3() { ), onSwitchClick = {}, hasNotificationPermission = false, + miningFeeSats = 250uL, + isConfirmFeeReady = true, isAdvanced = false ) } @@ -528,7 +551,7 @@ private fun Preview4() { onAdvancedClick = {}, onConfirm = {}, onUseDefaultLspBalanceClick = {}, - onTransferToSpendingConfirm = {}, + onTransferToSpendingConfirm = { true }, order = IBtOrder( id = "order_7e6f3b7c-486a-4f5a-8b1e-2c9d7f0a8b9d", state = BtOrderState.CREATED, @@ -590,6 +613,8 @@ private fun Preview4() { ), onSwitchClick = {}, hasNotificationPermission = true, + miningFeeSats = 250uL, + isConfirmFeeReady = true, isAdvanced = true ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 96683dbbc..0f2a4dc0e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -103,6 +103,8 @@ class TransferViewModel @Inject constructor( var maxLspFee = 0uL private var hwTransferSignJob: Job? = null private var hwFeeEstimateJob: Job? = null + private var confirmFeeJob: Job? = null + private var spendingConfirmFundingPlan: SpendingConfirmFundingPlan? = null private var pendingHwFundingBroadcast: PendingHwFundingBroadcast? = null private var activeHwTransferDeviceId: String? = null @@ -213,119 +215,175 @@ class TransferViewModel @Inject constructor( } } - /** Pays for the order and start watching it for state updates */ - @Suppress("LongMethod", "CyclomaticComplexMethod") - fun onTransferToSpendingConfirm(order: IBtOrder) { - viewModelScope.launch { - val address = order.payment?.onchain?.address.orEmpty() - val speed = TransactionSpeed.Fast - - // Use live spendableOnchainBalanceSats (not cached) to respect anchor reserves - val balanceDetails = lightningRepo.getBalancesAsync().getOrNull() - val spendableBalance = balanceDetails?.spendableOnchainBalanceSats ?: 0uL - val sendAllFee = lightningRepo.estimateSendAllFee( - address = address, - speed = speed, - ).getOrElse { - Logger.error("Failed to estimate send-all fee", it, context = TAG) - ToastEventBus.send(it) - return@launch + /** + * Match iOS SpendingConfirm.task: compute real mining fee + drain decision before swipe, + * so confirm UI can show fees up-front. + */ + fun prepareSpendingConfirmFunding(order: IBtOrder) { + confirmFeeJob?.cancel() + confirmFeeJob = viewModelScope.launch { + _spendingUiState.update { + it.copy(isConfirmFeeReady = false, miningFeeSats = 0uL) } - val maxSendable = spendableBalance.safe() - sendAllFee.safe() - - // Match iOS SpendingConfirm: try normal coin selection + fee first; drain only for - // real dust change on the selected inputs (never use send-all fee for this check). - var shouldUseSendAll = false - var selectedUtxos: List? = null - var normalFee = 0uL + buildSpendingConfirmFundingPlan(order) + .onSuccess { plan -> + spendingConfirmFundingPlan = plan + _spendingUiState.update { + it.copy( + isConfirmFeeReady = true, + miningFeeSats = plan.miningFeeSats, + shouldUseSendAll = plan.shouldUseSendAll, + ) + } + } + .onFailure { + spendingConfirmFundingPlan = null + _spendingUiState.update { + it.copy(isConfirmFeeReady = false, miningFeeSats = 0uL, shouldUseSendAll = false) + } + Logger.error("Failed to prepare transfer funding fee", it, context = TAG) + if (it is AppError) { + ToastEventBus.send(it) + } else { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.common__try_again), + ) + } + } + } + } - val satsPerVByte = lightningRepo.getFeeRateForSpeed(speed).getOrElse { - Logger.error("Failed to get transfer fee rate", it, context = TAG) + /** Pays for the order using the prepared confirm plan and starts watching it. */ + suspend fun onTransferToSpendingConfirm(order: IBtOrder): Boolean { + val plan = spendingConfirmFundingPlan?.takeIf { it.orderId == order.id } + ?: buildSpendingConfirmFundingPlan(order).getOrElse { + Logger.error("Failed to prepare transfer funding fee", it, context = TAG) ToastEventBus.send(it) - return@launch - } - - lightningRepo.selectUtxosWithAlgorithm( - targetAmountSats = order.feeSat, - satsPerVByte = satsPerVByte, - algorithm = CoinSelectionAlgorithm.LARGEST_FIRST, - ).onSuccess { utxos -> - normalFee = lightningRepo.calculateTotalFee( - amountSats = order.feeSat, - address = address, - speed = speed, - utxosToSpend = utxos, - ).getOrElse { - Logger.warn("Failed to estimate transfer funding fee", it, context = TAG) - 0uL - } - selectedUtxos = utxos - val totalInput = utxos.fold(0uL) { acc, utxo -> acc.safe() + utxo.valueSats.safe() } - shouldUseSendAll = wouldCreateDustChange( - totalInput = totalInput, - amountSats = order.feeSat, - normalFee = normalFee, - ) - }.onFailure { - Logger.warn("Normal coin selection failed, using sendAll", it, context = TAG) - shouldUseSendAll = true - } + return false + }.also { spendingConfirmFundingPlan = it } + + Logger.debug( + "BT confirm: spendable=${plan.spendableBalance}, feeSat=${order.feeSat}, " + + "miningFee=${plan.miningFeeSats}, selectedUtxos=${plan.selectedUtxos?.size}, " + + "sendAll=${plan.shouldUseSendAll}", + context = TAG, + ) - Logger.debug( - "BT confirm: spendable=$spendableBalance, feeSat=${order.feeSat}, " + - "sendAllFee=$sendAllFee, normalFee=$normalFee, " + - "selectedUtxos=${selectedUtxos?.size}, sendAll=$shouldUseSendAll", + if (plan.shouldUseSendAll && plan.maxSendable < order.feeSat) { + Logger.error( + "Insufficient balance for transfer: maxSendable=${plan.maxSendable}, " + + "orderFee=${order.feeSat}", context = TAG, ) + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__pay_insufficient_savings), + ) + return false + } - suspend fun fund( - isMaxAmount: Boolean, - txTotalSats: ULong, - utxosToSpend: List? = null, - ) = lightningRepo - .sendOnChain( - address = address, - sats = order.feeSat, - speed = speed, - utxosToSpend = utxosToSpend, - isTransfer = true, - channelId = order.channel?.shortChannelId, - isMaxAmount = isMaxAmount, + val address = order.payment?.onchain?.address.orEmpty() + return lightningRepo + .sendOnChain( + address = address, + sats = order.feeSat, + speed = TransactionSpeed.Fast, + utxosToSpend = if (plan.shouldUseSendAll) null else plan.selectedUtxos, + isTransfer = true, + channelId = order.channel?.shortChannelId, + isMaxAmount = plan.shouldUseSendAll, + ) + .onSuccess { txId -> + fundPaidOrder( + order = order, + txId = txId, + txTotalSats = if (plan.shouldUseSendAll) { + plan.spendableBalance + } else { + order.feeSat.safe() + plan.miningFeeSats.safe() + }, + preTransferOnchainSats = plan.totalOnchainBalance, ) - .onSuccess { txId -> - fundPaidOrder( - order = order, - txId = txId, - txTotalSats = txTotalSats, - preTransferOnchainSats = balanceDetails?.totalOnchainBalanceSats - ?: spendableBalance, - ) - } + } + .onFailure { ToastEventBus.send(it) } + .isSuccess + } - if (shouldUseSendAll) { - if (maxSendable < order.feeSat) { - Logger.error( - "Insufficient balance for transfer: maxSendable=$maxSendable, " + - "orderFee=${order.feeSat}", - context = TAG, - ) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.other__pay_insufficient_savings), - ) - return@launch - } - fund(isMaxAmount = true, txTotalSats = spendableBalance) - .onFailure { ToastEventBus.send(it) } - return@launch + @Suppress("LongMethod") + private suspend fun buildSpendingConfirmFundingPlan( + order: IBtOrder, + ): Result = runSuspendCatching { + val address = order.payment?.onchain?.address.orEmpty() + require(address.isNotEmpty()) { "Order payment onchain address is nil" } + + val speed = TransactionSpeed.Fast + val balanceDetails = lightningRepo.getBalancesAsync().getOrThrow() + val spendableBalance = balanceDetails.spendableOnchainBalanceSats + val totalOnchainBalance = balanceDetails.totalOnchainBalanceSats + val satsPerVByte = lightningRepo.getFeeRateForSpeed(speed).getOrThrow() + + // Match iOS SpendingConfirm: normal coin selection + fee first; drain only for real dust. + var shouldUseSendAll = false + var selectedUtxos: List? = null + var normalFee = 0uL + + lightningRepo.selectUtxosWithAlgorithm( + targetAmountSats = order.feeSat, + satsPerVByte = satsPerVByte, + algorithm = CoinSelectionAlgorithm.LARGEST_FIRST, + ).onSuccess { utxos -> + normalFee = lightningRepo.calculateTotalFee( + amountSats = order.feeSat, + address = address, + speed = speed, + utxosToSpend = utxos, + ).getOrElse { + Logger.warn("Failed to estimate transfer funding fee", it, context = TAG) + 0uL } + selectedUtxos = utxos + val totalInput = utxos.fold(0uL) { acc, utxo -> acc.safe() + utxo.valueSats.safe() } + shouldUseSendAll = wouldCreateDustChange( + totalInput = totalInput, + amountSats = order.feeSat, + normalFee = normalFee, + ) + }.onFailure { + Logger.warn("Normal coin selection failed, using sendAll", it, context = TAG) + shouldUseSendAll = true + } - fund( - isMaxAmount = false, - txTotalSats = order.feeSat.safe() + normalFee.safe(), - utxosToSpend = selectedUtxos, - ).onFailure { ToastEventBus.send(it) } + if (!shouldUseSendAll) { + return@runSuspendCatching SpendingConfirmFundingPlan( + orderId = order.id, + miningFeeSats = normalFee, + shouldUseSendAll = false, + selectedUtxos = selectedUtxos, + spendableBalance = spendableBalance, + totalOnchainBalance = totalOnchainBalance, + maxSendable = 0uL, + ) } + + val sendAllFee = lightningRepo.estimateSendAllFee( + address = address, + speed = speed, + ).getOrThrow() + val maxSendable = spendableBalance.safe() - sendAllFee.safe() + if (maxSendable < order.feeSat) { + throw AppError(context.getString(R.string.other__pay_insufficient_savings)) + } + + SpendingConfirmFundingPlan( + orderId = order.id, + miningFeeSats = sendAllFee, + shouldUseSendAll = true, + selectedUtxos = null, + spendableBalance = spendableBalance, + totalOnchainBalance = totalOnchainBalance, + maxSendable = maxSendable, + ) } private fun wouldCreateDustChange( @@ -589,6 +647,9 @@ class TransferViewModel @Inject constructor( hwTransferSignJob = null hwFeeEstimateJob?.cancel() hwFeeEstimateJob = null + confirmFeeJob?.cancel() + confirmFeeJob = null + spendingConfirmFundingPlan = null pendingHwFundingBroadcast = null activeHwTransferDeviceId = null _spendingUiState.update { TransferToSpendingUiState() } @@ -1226,10 +1287,24 @@ data class TransferToSpendingUiState( val isSigning: Boolean = false, val hasPendingHwBroadcast: Boolean = false, val hwMiningFeeSats: ULong = 0uL, + /** Real on-chain mining fee for soft-wallet confirm (iOS transactionFee). */ + val miningFeeSats: ULong = 0uL, + val isConfirmFeeReady: Boolean = false, + val shouldUseSendAll: Boolean = false, val receivingAmount: Long = 0, val feeEstimate: Long? = null, ) +private data class SpendingConfirmFundingPlan( + val orderId: String, + val miningFeeSats: ULong, + val shouldUseSendAll: Boolean, + val selectedUtxos: List?, + val spendableBalance: ULong, + val totalOnchainBalance: ULong, + val maxSendable: ULong, +) + data class TransferValues( val defaultLspBalance: ULong = 0u, val minLspBalance: ULong = 0u, diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index c5536fa99..1534e45d4 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -363,6 +363,26 @@ class TransferViewModelTest : BaseUnitTest() { assertEquals(999uL, sut.spendingUiState.value.hwMiningFeeSats) } + @Test + fun `prepareSpendingConfirmFunding exposes real mining fee for confirm UI`() = test { + val order = previewBtOrder(feeSat = 98_000uL) + val selected = listOf(stubUtxo(100_000u)) + stubSpendableBalances(spendable = 100_000u) + whenever { + lightningRepo.selectUtxosWithAlgorithm(any(), any(), any(), anyOrNull()) + }.thenReturn(Result.success(selected)) + whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())) + .thenReturn(Result.success(1_000uL)) + + sut.prepareSpendingConfirmFunding(order) + advanceUntilIdle() + + val state = sut.spendingUiState.value + assertEquals(true, state.isConfirmFeeReady) + assertEquals(1_000uL, state.miningFeeSats) + assertEquals(false, state.shouldUseSendAll) + } + @Test fun `onTransferToSpendingConfirm uses send-all when selected inputs would create dust change`() = test { val order = previewBtOrder(feeSat = 99_000uL) @@ -377,9 +397,10 @@ class TransferViewModelTest : BaseUnitTest() { .thenReturn(Result.success(500uL)) stubSendOnChainSuccess() - sut.onTransferToSpendingConfirm(order) + val paid = sut.onTransferToSpendingConfirm(order) advanceUntilIdle() + assertEquals(true, paid) verify(lightningRepo).selectUtxosWithAlgorithm( targetAmountSats = eq(order.feeSat), satsPerVByte = any(), @@ -416,9 +437,10 @@ class TransferViewModelTest : BaseUnitTest() { .thenReturn(Result.success(2_830uL)) stubSendOnChainSuccess() - sut.onTransferToSpendingConfirm(order) + val paid = sut.onTransferToSpendingConfirm(order) advanceUntilIdle() + assertEquals(true, paid) verify(lightningRepo).sendOnChain( address = eq(order.payment?.onchain?.address.orEmpty()), sats = eq(order.feeSat), @@ -472,9 +494,10 @@ class TransferViewModelTest : BaseUnitTest() { ), ).thenReturn(Result.failure(AppError("Coin selection failed"))) - sut.onTransferToSpendingConfirm(order) + val paid = sut.onTransferToSpendingConfirm(order) advanceUntilIdle() + assertEquals(false, paid) verify(lightningRepo, times(1)).sendOnChain( address = eq(order.payment?.onchain?.address.orEmpty()), sats = eq(order.feeSat),