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/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index b41ddca8e3..6d6615fe64 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -649,9 +649,7 @@ private fun RootNavHost( val scope = rememberCoroutineScope() LaunchedEffect(Unit) { transferViewModel.transferEffects.collect { effect -> - if (effect is TransferEffect.OnHwTxSigned) { - navController.navigateTo(Routes.SpendingHwSigned) - } + transferEffectDestination(effect)?.let { navController.navigateTo(it) } } } @@ -830,7 +828,6 @@ private fun RootNavHost( onCloseClick = { navController.navigateToHome() }, onLearnMoreClick = { navController.navigateTo(Routes.TransferLiquidity) }, onAdvancedClick = { navController.navigateTo(Routes.SpendingAdvanced) }, - onConfirm = { navController.navigateTo(Routes.SettingUp) }, ) } composableWithDefaultTransitions { @@ -1841,6 +1838,12 @@ fun NavController.navigateToTransferSpendingStart( deviceId: String, ) = navigateTo(transferSpendingStartRoute(hasSeenSpendingIntro, deviceId)) +internal fun transferEffectDestination(effect: TransferEffect): Routes? = when (effect) { + TransferEffect.OnHwTxSigned -> Routes.SpendingHwSigned + TransferEffect.OnSpendingFundingPaid -> Routes.SettingUp + else -> null +} + internal fun transferSpendingStartRoute(hasSeenSpendingIntro: Boolean): Routes = when { hasSeenSpendingIntro -> Routes.SpendingAmount else -> Routes.SpendingIntro 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..e4c67598aa 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,11 +17,8 @@ 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 -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale @@ -44,9 +41,8 @@ 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 @@ -79,7 +75,6 @@ fun SpendingConfirmScreen( onCloseClick: () -> Unit = {}, onLearnMoreClick: () -> Unit = {}, onAdvancedClick: () -> Unit = {}, - onConfirm: () -> Unit = {}, settingsViewModel: SettingsViewModel = hiltViewModel(), ) { val context = LocalContext.current @@ -91,6 +86,13 @@ fun SpendingConfirmScreen( return } val isAdvanced = state.isAdvanced + val miningFeeSats = state.miningFeeSats + val isConfirmFeeReady = state.isConfirmFeeReady + val isConfirmPaying = state.isConfirmPaying + + LaunchedEffect(order.id, order.feeSat) { + viewModel.prepareSpendingConfirmFunding(order) + } val notificationsGranted by settingsViewModel.notificationsGranted.collectAsStateWithLifecycle() @@ -112,10 +114,12 @@ fun SpendingConfirmScreen( onBackClick = onBackClick, onLearnMoreClick = onLearnMoreClick, onAdvancedClick = onAdvancedClick, - onConfirm = onConfirm, onUseDefaultLspBalanceClick = viewModel::onUseDefaultLspBalanceClick, - onTransferToSpendingConfirm = viewModel::onTransferToSpendingConfirm, + onTransferToSpendingConfirm = { viewModel.onTransferToSpendingConfirm(order) }, order = order, + miningFeeSats = miningFeeSats, + isConfirmFeeReady = isConfirmFeeReady, + isConfirmPaying = isConfirmPaying, hasNotificationPermission = notificationsGranted, onSwitchClick = onNotificationSwitchClick, isAdvanced = isAdvanced, @@ -133,22 +137,22 @@ fun SpendingConfirmScreen( } } -@Suppress("MagicNumber") +@Suppress("MagicNumber", "LongMethod") @Composable private fun Content( onBackClick: () -> Unit, onLearnMoreClick: () -> Unit, onAdvancedClick: () -> Unit, - onConfirm: () -> Unit, onUseDefaultLspBalanceClick: () -> Unit, onSwitchClick: () -> Unit, hasNotificationPermission: Boolean, - onTransferToSpendingConfirm: (IBtOrder) -> Unit, + onTransferToSpendingConfirm: () -> Unit, order: IBtOrder, + miningFeeSats: ULong, + isConfirmFeeReady: Boolean, + isConfirmPaying: Boolean, isAdvanced: Boolean, ) { - val scope = rememberCoroutineScope() - ScreenColumn { AppTopBar( titleText = stringResource(R.string.lightning__transfer__nav_title), @@ -175,10 +179,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 +195,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 +212,7 @@ private fun Content( ) FeeInfo( label = stringResource(R.string.lightning__spending_confirm__total), - amount = totalFee.toLong(), + amount = total.toLong(), ) } @@ -267,18 +271,15 @@ 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 && !isConfirmPaying SwipeToConfirm( text = stringResource(R.string.lightning__transfer__swipe), - loading = isLoading, + loading = isConfirmPaying || !isConfirmFeeReady, color = Colors.Purple, onConfirm = { - scope.launch { - isLoading = true - delay(300) - onTransferToSpendingConfirm(order) - onConfirm() - } + if (!canConfirm) return@SwipeToConfirm + onTransferToSpendingConfirm() }, ) VerticalSpacer(16.dp) @@ -295,7 +296,6 @@ private fun Preview() { onBackClick = {}, onLearnMoreClick = {}, onAdvancedClick = {}, - onConfirm = {}, onUseDefaultLspBalanceClick = {}, onTransferToSpendingConfirm = {}, order = IBtOrder( @@ -359,6 +359,9 @@ private fun Preview() { ), onSwitchClick = {}, hasNotificationPermission = true, + miningFeeSats = 250uL, + isConfirmFeeReady = true, + isConfirmPaying = false, isAdvanced = false ) } @@ -372,7 +375,6 @@ private fun Preview2() { onBackClick = {}, onLearnMoreClick = {}, onAdvancedClick = {}, - onConfirm = {}, onUseDefaultLspBalanceClick = {}, onTransferToSpendingConfirm = {}, order = IBtOrder( @@ -436,6 +438,9 @@ private fun Preview2() { ), onSwitchClick = {}, hasNotificationPermission = true, + miningFeeSats = 250uL, + isConfirmFeeReady = true, + isConfirmPaying = false, isAdvanced = true ) } @@ -449,7 +454,6 @@ private fun Preview3() { onBackClick = {}, onLearnMoreClick = {}, onAdvancedClick = {}, - onConfirm = {}, onUseDefaultLspBalanceClick = {}, onTransferToSpendingConfirm = {}, order = IBtOrder( @@ -513,6 +517,9 @@ private fun Preview3() { ), onSwitchClick = {}, hasNotificationPermission = false, + miningFeeSats = 250uL, + isConfirmFeeReady = true, + isConfirmPaying = false, isAdvanced = false ) } @@ -526,7 +533,6 @@ private fun Preview4() { onBackClick = {}, onLearnMoreClick = {}, onAdvancedClick = {}, - onConfirm = {}, onUseDefaultLspBalanceClick = {}, onTransferToSpendingConfirm = {}, order = IBtOrder( @@ -590,6 +596,9 @@ private fun Preview4() { ), onSwitchClick = {}, hasNotificationPermission = true, + miningFeeSats = 250uL, + isConfirmFeeReady = true, + isConfirmPaying = false, 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..74e779431f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -9,6 +9,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -25,9 +26,12 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext 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 +105,9 @@ class TransferViewModel @Inject constructor( var maxLspFee = 0uL private var hwTransferSignJob: Job? = null private var hwFeeEstimateJob: Job? = null + private var confirmFeeJob: Job? = null + private var confirmPayJob: Job? = null + private var spendingConfirmFundingPlan: SpendingConfirmFundingPlan? = null private var pendingHwFundingBroadcast: PendingHwFundingBroadcast? = null private var activeHwTransferDeviceId: String? = null @@ -211,73 +218,235 @@ 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() - - // 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) } + 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 expectedChange = - spendableBalance.toLong() - order.feeSat.toLong() - sendAllFee.toLong() - val shouldUseSendAll = - expectedChange >= 0 && expectedChange < TRANSFER_SEND_ALL_THRESHOLD_SATS + /** Pays for the order using the prepared confirm plan and starts watching it. */ + fun onTransferToSpendingConfirm(order: IBtOrder) { + if (confirmPayJob?.isActive == true) return - 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 + confirmPayJob = viewModelScope.launch { + _spendingUiState.update { it.copy(isConfirmPaying = true) } + try { + val paid = runSuspendCatching { + paySpendingConfirmOrder(order) + }.onFailure { + Logger.error("Failed to pay spending confirm order", it, context = TAG) + ToastEventBus.send(it) + }.getOrDefault(false) + + if (paid) { + // Emit from this job (not a nested launch) so navigation is not raced/lost. + transferEffects.emit(TransferEffect.OnSpendingFundingPaid) + } else { + _spendingUiState.update { it.copy(isConfirmPaying = false) } } + } finally { + confirmPayJob = null } - val txTotalSats = if (shouldUseSendAll) { - spendableBalance - } else { - order.feeSat.safe() + miningFee.safe() - } + } + } + + private suspend fun paySpendingConfirmOrder(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 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, expectedChange=$expectedChange, 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 + } - lightningRepo - .sendOnChain( - address = address, - sats = order.feeSat, - speed = speed, - isTransfer = true, - channelId = order.channel?.shortChannelId, - isMaxAmount = shouldUseSendAll, - ) - .onSuccess { txId -> + 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 -> + // Survive ViewModel clearance between broadcast and paid-order cache write. + withContext(NonCancellable) { fundPaidOrder( order = order, txId = txId, - txTotalSats = txTotalSats, - preTransferOnchainSats = balanceDetails?.totalOnchainBalanceSats ?: spendableBalance, + txTotalSats = if (plan.shouldUseSendAll) { + plan.spendableBalance + } else { + order.feeSat.safe() + plan.miningFeeSats.safe() + }, + preTransferOnchainSats = plan.totalOnchainBalance, ) } - .onFailure { error -> - ToastEventBus.send(error) - } + } + .onFailure { ToastEventBus.send(it) } + .isSuccess + } + + 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. + resolveNormalSpendingConfirmFunding( + order = order, + address = address, + speed = speed, + satsPerVByte = satsPerVByte, + spendableBalance = spendableBalance, + totalOnchainBalance = totalOnchainBalance, + ) ?: resolveSendAllSpendingConfirmFunding( + order = order, + address = address, + speed = speed, + spendableBalance = spendableBalance, + totalOnchainBalance = totalOnchainBalance, + ) + } + + private suspend fun resolveNormalSpendingConfirmFunding( + order: IBtOrder, + address: String, + speed: TransactionSpeed, + satsPerVByte: ULong, + spendableBalance: ULong, + totalOnchainBalance: ULong, + ): SpendingConfirmFundingPlan? { + val utxos = lightningRepo.selectUtxosWithAlgorithm( + targetAmountSats = order.feeSat, + satsPerVByte = satsPerVByte, + algorithm = CoinSelectionAlgorithm.LARGEST_FIRST, + ).getOrElse { + Logger.warn("Normal coin selection failed, using sendAll", it, context = TAG) + return null + } + + val 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 + } + val totalInput = utxos.fold(0uL) { acc, utxo -> acc.safe() + utxo.valueSats.safe() } + if (wouldCreateDustChange(totalInput = totalInput, amountSats = order.feeSat, normalFee = normalFee)) { + return null } + + return SpendingConfirmFundingPlan( + orderId = order.id, + miningFeeSats = normalFee, + shouldUseSendAll = false, + selectedUtxos = utxos, + spendableBalance = spendableBalance, + totalOnchainBalance = totalOnchainBalance, + maxSendable = 0uL, + ) + } + + private suspend fun resolveSendAllSpendingConfirmFunding( + order: IBtOrder, + address: String, + speed: TransactionSpeed, + spendableBalance: ULong, + totalOnchainBalance: ULong, + ): SpendingConfirmFundingPlan { + 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)) + } + + return 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 +584,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 +700,10 @@ class TransferViewModel @Inject constructor( hwTransferSignJob = null hwFeeEstimateJob?.cancel() hwFeeEstimateJob = null + confirmFeeJob?.cancel() + confirmFeeJob = null + // Do not cancel confirmPayJob: broadcast + paid-order cache must finish. + spendingConfirmFundingPlan = null pendingHwFundingBroadcast = null activeHwTransferDeviceId = null _spendingUiState.update { TransferToSpendingUiState() } @@ -683,9 +866,7 @@ class TransferViewModel @Inject constructor( } broadcastHardwareFunding(signedTx) } - result.exceptionOrNull()?.let { - if (it is CancellationException && it !is TimeoutCancellationException) throw it - } + result.exceptionOrNull()?.rethrowIfCancellation() return result } @@ -713,7 +894,7 @@ class TransferViewModel @Inject constructor( hwWalletRepo.ensureConnected(deviceId).getOrThrow() } }.getOrElse { - if (it is CancellationException && it !is TimeoutCancellationException) throw it + it.rethrowIfCancellation() if (it.isTrezorUserCancellation()) throw it throw HardwareReconnectError(it) } @@ -734,7 +915,7 @@ class TransferViewModel @Inject constructor( ).getOrThrow() } }.getOrElse { - if (it is CancellationException && it !is TimeoutCancellationException) throw it + it.rethrowIfCancellation() throw HardwareFundingError(it) } @@ -751,7 +932,7 @@ class TransferViewModel @Inject constructor( ).getOrThrow() } }.getOrElse { - if (it is CancellationException && it !is TimeoutCancellationException) throw it + it.rethrowIfCancellation() if (it is TimeoutCancellationException) { hwWalletRepo.disconnectStaleSession(deviceId) throw HardwareSigningTimeoutError(it) @@ -768,7 +949,7 @@ class TransferViewModel @Inject constructor( hwWalletRepo.broadcastFunding(signedTx).getOrThrow() } }.getOrElse { - if (it is CancellationException && it !is TimeoutCancellationException) throw it + it.rethrowIfCancellation() throw HardwareBroadcastError(it) } } @@ -1097,7 +1278,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 +1337,25 @@ 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 isConfirmPaying: 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, @@ -1170,8 +1365,13 @@ data class TransferValues( sealed interface TransferEffect { data object OnOrderCreated : TransferEffect + data object OnSpendingFundingPaid : TransferEffect data object OnHwTxSigned : TransferEffect data class ToastException(val e: Throwable) : TransferEffect data class ToastError(val title: String, val description: String) : TransferEffect } // endregion + +private fun Throwable.rethrowIfCancellation() { + if (this is CancellationException && this !is TimeoutCancellationException) throw this +} diff --git a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt index 791ba05196..995c778e03 100644 --- a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt +++ b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt @@ -1,7 +1,9 @@ package to.bitkit.ui import org.junit.Test +import to.bitkit.viewmodels.TransferEffect import kotlin.test.assertEquals +import kotlin.test.assertNull class ContentViewTest { @Test @@ -17,4 +19,11 @@ class ContentViewTest { assertEquals(Routes.SpendingIntroHw(deviceId), transferSpendingStartRoute(false, deviceId)) assertEquals(Routes.SpendingAmountHw(deviceId), transferSpendingStartRoute(true, deviceId)) } + + @Test + fun `transfer effect destinations cover funding paid and hw signed`() { + assertEquals(Routes.SettingUp, transferEffectDestination(TransferEffect.OnSpendingFundingPaid)) + assertEquals(Routes.SpendingHwSigned, transferEffectDestination(TransferEffect.OnHwTxSigned)) + assertNull(transferEffectDestination(TransferEffect.OnOrderCreated)) + } } diff --git a/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt index 1173efe391..6ed2be0632 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,175 @@ 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() + var fundingPaidEmitted = false + backgroundScope.launch { + sut.transferEffects.collect { effect -> + if (effect is TransferEffect.OnSpendingFundingPaid) { + fundingPaidEmitted = true + } + } + } + + sut.onTransferToSpendingConfirm(order) + advanceUntilIdle() + + assertEquals(true, sut.spendingUiState.value.isConfirmPaying) + assertTrue(fundingPaidEmitted) + 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() + + sut.onTransferToSpendingConfirm(order) + advanceUntilIdle() + + assertEquals(true, sut.spendingUiState.value.isConfirmPaying) + 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"))) + + sut.onTransferToSpendingConfirm(order) + advanceUntilIdle() + + assertEquals(false, sut.spendingUiState.value.isConfirmPaying) + 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 +1237,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.