diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 1e8800355c..28d120995e 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/ui/screens/transfer/SpendingConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingConfirmScreen.kt index a543c7c37f..6640f1e626 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 e48d5ef40e..213a6a6567 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 @@ -101,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 @@ -211,73 +215,185 @@ class TransferViewModel @Inject constructor( } } - /** Pays for the order and start watching it for state updates */ - fun onTransferToSpendingConfirm(order: IBtOrder, speed: TransactionSpeed? = null) { - viewModelScope.launch { - val address = order.payment?.onchain?.address.orEmpty() + /** + * 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) + } + 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), + ) + } + } + } + } - // 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) + /** 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 - } + 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, + ) - val expectedChange = - spendableBalance.toLong() - order.feeSat.toLong() - sendAllFee.toLong() - val shouldUseSendAll = - expectedChange >= 0 && expectedChange < TRANSFER_SEND_ALL_THRESHOLD_SATS + 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 + } - 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 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, + ) } - val txTotalSats = if (shouldUseSendAll) { - spendableBalance - } else { - order.feeSat.safe() + miningFee.safe() + .onFailure { ToastEventBus.send(it) } + .isSuccess + } + + @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 + } - Logger.debug( - "BT confirm: spendable=$spendableBalance, feeSat=${order.feeSat}, " + - "sendAllFee=$sendAllFee, expectedChange=$expectedChange, sendAll=$shouldUseSendAll", - context = TAG, + if (!shouldUseSendAll) { + return@runSuspendCatching SpendingConfirmFundingPlan( + orderId = order.id, + miningFeeSats = normalFee, + shouldUseSendAll = false, + selectedUtxos = selectedUtxos, + spendableBalance = spendableBalance, + totalOnchainBalance = totalOnchainBalance, + maxSendable = 0uL, ) + } - lightningRepo - .sendOnChain( - address = address, - sats = order.feeSat, - speed = speed, - isTransfer = true, - channelId = order.channel?.shortChannelId, - isMaxAmount = shouldUseSendAll, - ) - .onSuccess { txId -> - fundPaidOrder( - order = order, - txId = txId, - txTotalSats = txTotalSats, - preTransferOnchainSats = balanceDetails?.totalOnchainBalanceSats ?: spendableBalance, - ) - } - .onFailure { error -> - ToastEventBus.send(error) - } + 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( + 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). */ @@ -415,10 +531,20 @@ 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 = spendable.safe() - miningFee.safe() + val initialLspFees = estimateInitialLspFees(availableAmount) if (initialLspFees == null) { _spendingUiState.update { it.copy(isLoading = false) } @@ -521,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() } @@ -1097,7 +1226,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 @@ -1157,10 +1285,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 1173efe391..80c0e5d8e2 100644 --- a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt @@ -27,7 +27,11 @@ 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.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 @@ -104,6 +108,14 @@ 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)) + 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, @@ -120,7 +132,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. @@ -142,7 +154,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))) @@ -158,7 +170,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))) @@ -170,6 +182,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. @@ -301,6 +363,166 @@ 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) + 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() + + val paid = sut.onTransferToSpendingConfirm(order) + advanceUntilIdle() + + assertEquals(true, paid) + 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), + 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 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() + + val paid = sut.onTransferToSpendingConfirm(order) + advanceUntilIdle() + + assertEquals(true, paid) + 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 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_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( + lightningRepo.sendOnChain( + any(), + any(), + any(), + anyOrNull(), + anyOrNull(), + any(), + anyOrNull(), + any(), + any(), + ), + ).thenReturn(Result.failure(AppError("Coin selection failed"))) + + 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), + 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, never()).addPaidOrder(any(), any()) + } + @Test fun `onTransferToSpendingHwConfirm signs the funding send and records the paid order`() = test { val order = previewBtOrder() @@ -1006,6 +1228,39 @@ 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 fun stubUtxo(valueSats: ULong): SpendableUtxo = SpendableUtxo( + outpoint = OutPoint(txid = "stub-utxo-txid", vout = 0u), + valueSats = valueSats, + ) + + 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 0000000000..cf3245d661 --- /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.