diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index e66ccaf7e..a9f8d6b79 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) } + } } } @@ -415,10 +433,21 @@ class TransferViewModel @Inject constructor( viewModelScope.launch { _spendingUiState.update { it.copy(isLoading = true) } - val availableAmount = walletRepo.balanceState.value.maxSendOnchainSats - 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 = + 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 ccd100f41..9995ba9a3 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 @@ -51,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 @@ -103,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, @@ -119,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. @@ -141,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))) @@ -157,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))) @@ -169,6 +174,56 @@ 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 + // 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)) + 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 `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. @@ -300,6 +355,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 +1189,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/1091.fixed.md b/changelog.d/next/1091.fixed.md new file mode 100644 index 000000000..cf3245d66 --- /dev/null +++ b/changelog.d/next/1091.fixed.md @@ -0,0 +1 @@ +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.