From d35ffc3562e61561008ce98cf86649a753ef6539 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 29 Jan 2026 08:35:54 -0300 Subject: [PATCH 1/6] fix: used provided amount instead of all amount to calculate the fee --- .../to/bitkit/viewmodels/TransferViewModel.kt | 49 ++++++++++++++----- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 9a83e0472..9a144c828 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -297,30 +297,53 @@ class TransferViewModel @Inject constructor( isNodeRunning.first { it } } - // Calculate the LSP fee to the total balance - blocktankRepo.estimateOrderFee( + // Two-pass fee estimation to match actual order creation + // First pass: estimate with availableAmount to get approximate clientBalance + val values1 = blocktankRepo.calculateLiquidityOptions(availableAmount).getOrNull() + if (values1 == null) { + _spendingUiState.update { it.copy(isLoading = false) } + return@launch + } + val lspBalance1 = maxOf(values1.defaultLspBalanceSat, values1.minLspBalanceSat) + val feeEstimate1 = blocktankRepo.estimateOrderFee( spendingBalanceSats = availableAmount, - receivingBalanceSats = _transferValues.value.maxLspBalance + receivingBalanceSats = lspBalance1, + ).getOrNull() + + if (feeEstimate1 == null) { + _spendingUiState.update { it.copy(isLoading = false) } + return@launch + } + + val lspFees1 = feeEstimate1.networkFeeSat.safe() + feeEstimate1.serviceFeeSat.safe() + val approxClientBalance = availableAmount.safe() - lspFees1.safe() + + // Second pass: recalculate with actual clientBalance that order creation will use + val values2 = blocktankRepo.calculateLiquidityOptions(approxClientBalance).getOrNull() + if (values2 == null || values2.maxLspBalanceSat == 0uL) { + _spendingUiState.update { it.copy(isLoading = false, maxAllowedToSend = 0) } + return@launch + } + val lspBalance2 = maxOf(values2.defaultLspBalanceSat, values2.minLspBalanceSat) + + blocktankRepo.estimateOrderFee( + spendingBalanceSats = approxClientBalance, + receivingBalanceSats = lspBalance2, ).onSuccess { estimate -> maxLspFee = estimate.feeSat - - // Calculate the available balance to send after LSP fee - val balanceAfterLspFee = availableAmount.safe() - maxLspFee.safe() + val lspFees = estimate.networkFeeSat.safe() + estimate.serviceFeeSat.safe() + val maxClientBalance = availableAmount.safe() - lspFees.safe() _spendingUiState.update { - // Calculate the max available to send considering the current balance and LSP policy it.copy( - maxAllowedToSend = min( - _transferValues.value.maxClientBalance.toLong(), - balanceAfterLspFee.toLong() - ), + maxAllowedToSend = min(values2.maxClientBalanceSat.toLong(), maxClientBalance.toLong()), isLoading = false, - balanceAfterFee = availableAmount.toLong() + balanceAfterFee = availableAmount.toLong(), ) } }.onFailure { exception -> _spendingUiState.update { it.copy(isLoading = false) } - Logger.error("Failure", exception) + Logger.error("Failure", exception, context = TAG) setTransferEffect(TransferEffect.ToastException(exception)) } } From f781d8b92a6682a8a0b15fc3e5a133fa52be4844 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 29 Jan 2026 08:36:48 -0300 Subject: [PATCH 2/6] fix: use isMaxAmount when the remaining value is dust --- .../java/to/bitkit/viewmodels/TransferViewModel.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 9a144c828..83dbc06c1 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -28,6 +28,7 @@ import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore +import to.bitkit.env.Defaults import to.bitkit.ext.amountOnClose import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed @@ -194,6 +195,18 @@ class TransferViewModel @Inject constructor( viewModelScope.launch { val address = order.payment?.onchain?.address.orEmpty() + // Calculate if change would be dust and we should use sendAll + val spendableBalance = + lightningRepo.lightningState.value.balances?.spendableOnchainBalanceSats ?: 0uL + val txFee = lightningRepo.calculateTotalFee( + amountSats = spendableBalance, + address = address, + speed = speed, + ).getOrElse { 0uL } + + val expectedChange = spendableBalance.toLong() - order.feeSat.toLong() - txFee.toLong() + val shouldUseSendAll = expectedChange >= 0 && expectedChange < Defaults.dustLimit.toInt() + lightningRepo .sendOnChain( address = address, @@ -201,6 +214,7 @@ class TransferViewModel @Inject constructor( speed = speed, isTransfer = true, channelId = order.channel?.shortChannelId, + isMaxAmount = shouldUseSendAll, ) .onSuccess { txId -> cacheStore.addPaidOrder(orderId = order.id, txId = txId) From 3a7eefb8ce118a23ed7e31414f7cb42259d533bb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 9 Feb 2026 14:50:46 -0300 Subject: [PATCH 3/6] fix: use order.feeSat (the amount calculated to transfer) instead of spendableBalance to calculate the txFee --- app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 83dbc06c1..b932e6652 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -199,7 +199,7 @@ class TransferViewModel @Inject constructor( val spendableBalance = lightningRepo.lightningState.value.balances?.spendableOnchainBalanceSats ?: 0uL val txFee = lightningRepo.calculateTotalFee( - amountSats = spendableBalance, + amountSats = order.feeSat, address = address, speed = speed, ).getOrElse { 0uL } From 667e982e4da790962fc31a5dde57d0d641a80eb0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 12 Feb 2026 08:11:19 -0300 Subject: [PATCH 4/6] chore: improve comments --- app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt | 6 +++--- 1 file changed, 3 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 bcf15c4dc..6818ea1e9 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -332,8 +332,8 @@ class TransferViewModel @Inject constructor( isNodeRunning.first { it } } - // Two-pass fee estimation to match actual order creation - // First pass: estimate with availableAmount to get approximate clientBalance + // Two-step fee estimation to match actual order creation + // First step: estimate with availableAmount to get approximate clientBalance val values1 = blocktankRepo.calculateLiquidityOptions(availableAmount).getOrNull() if (values1 == null) { _spendingUiState.update { it.copy(isLoading = false) } @@ -353,7 +353,7 @@ class TransferViewModel @Inject constructor( val lspFees1 = feeEstimate1.networkFeeSat.safe() + feeEstimate1.serviceFeeSat.safe() val approxClientBalance = availableAmount.safe() - lspFees1.safe() - // Second pass: recalculate with actual clientBalance that order creation will use + // Second step: recalculate with actual clientBalance that order creation will use val values2 = blocktankRepo.calculateLiquidityOptions(approxClientBalance).getOrNull() if (values2 == null || values2.maxLspBalanceSat == 0uL) { _spendingUiState.update { it.copy(isLoading = false, maxAllowedToSend = 0) } From 02ed257855b85b82c3403d718f2ff1f6ac478a0f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 12 Feb 2026 08:19:28 -0300 Subject: [PATCH 5/6] refactor: implement meaningful names to variables --- .../to/bitkit/viewmodels/TransferViewModel.kt | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 6818ea1e9..096d43516 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -334,36 +334,48 @@ class TransferViewModel @Inject constructor( // Two-step fee estimation to match actual order creation // First step: estimate with availableAmount to get approximate clientBalance - val values1 = blocktankRepo.calculateLiquidityOptions(availableAmount).getOrNull() - if (values1 == null) { + val liquidityFromAvailableBalance = blocktankRepo + .calculateLiquidityOptions(availableAmount) + .getOrNull() + + if (liquidityFromAvailableBalance == null) { _spendingUiState.update { it.copy(isLoading = false) } return@launch } - val lspBalance1 = maxOf(values1.defaultLspBalanceSat, values1.minLspBalanceSat) - val feeEstimate1 = blocktankRepo.estimateOrderFee( + + val lspBalance1 = maxOf( + liquidityFromAvailableBalance.defaultLspBalanceSat, + liquidityFromAvailableBalance.minLspBalanceSat + ) + + val orderFeeFromAvailableAmount = blocktankRepo.estimateOrderFee( spendingBalanceSats = availableAmount, receivingBalanceSats = lspBalance1, ).getOrNull() - if (feeEstimate1 == null) { + if (orderFeeFromAvailableAmount == null) { _spendingUiState.update { it.copy(isLoading = false) } return@launch } - val lspFees1 = feeEstimate1.networkFeeSat.safe() + feeEstimate1.serviceFeeSat.safe() - val approxClientBalance = availableAmount.safe() - lspFees1.safe() + val lspFeesFromAvailableAmount = + orderFeeFromAvailableAmount.networkFeeSat.safe() + orderFeeFromAvailableAmount.serviceFeeSat.safe() + val balanceAfterLspFee = availableAmount.safe() - lspFeesFromAvailableAmount.safe() // Second step: recalculate with actual clientBalance that order creation will use - val values2 = blocktankRepo.calculateLiquidityOptions(approxClientBalance).getOrNull() - if (values2 == null || values2.maxLspBalanceSat == 0uL) { + val liquidityAfterLspFee = blocktankRepo.calculateLiquidityOptions(balanceAfterLspFee).getOrNull() + if (liquidityAfterLspFee == null || liquidityAfterLspFee.maxLspBalanceSat == 0uL) { _spendingUiState.update { it.copy(isLoading = false, maxAllowedToSend = 0) } return@launch } - val lspBalance2 = maxOf(values2.defaultLspBalanceSat, values2.minLspBalanceSat) + val receivingAmountAfterFee = maxOf( + liquidityAfterLspFee.defaultLspBalanceSat, + liquidityAfterLspFee.minLspBalanceSat + ) blocktankRepo.estimateOrderFee( - spendingBalanceSats = approxClientBalance, - receivingBalanceSats = lspBalance2, + spendingBalanceSats = balanceAfterLspFee, + receivingBalanceSats = receivingAmountAfterFee, ).onSuccess { estimate -> maxLspFee = estimate.feeSat val lspFees = estimate.networkFeeSat.safe() + estimate.serviceFeeSat.safe() @@ -371,7 +383,10 @@ class TransferViewModel @Inject constructor( _spendingUiState.update { it.copy( - maxAllowedToSend = min(values2.maxClientBalanceSat.toLong(), maxClientBalance.toLong()), + maxAllowedToSend = min( + liquidityAfterLspFee.maxClientBalanceSat.toLong(), + maxClientBalance.toLong() + ), isLoading = false, balanceAfterFee = availableAmount.toLong(), ) From c2c9b073c7fc01bb6e33c19c864ee1724674847b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 12 Feb 2026 08:59:11 -0300 Subject: [PATCH 6/6] refactor: split balance update logic into private methods --- .../to/bitkit/viewmodels/TransferViewModel.kt | 114 +++++++++--------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 096d43516..e5d5946b7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -325,77 +325,77 @@ class TransferViewModel @Inject constructor( viewModelScope.launch { _spendingUiState.update { it.copy(isLoading = true) } - // Get the max available balance discounting onChain fee val availableAmount = walletRepo.balanceState.value.maxSendOnchainSats - withTimeoutOrNull(1.minutes) { - isNodeRunning.first { it } - } - - // Two-step fee estimation to match actual order creation - // First step: estimate with availableAmount to get approximate clientBalance - val liquidityFromAvailableBalance = blocktankRepo - .calculateLiquidityOptions(availableAmount) - .getOrNull() + awaitNodeRunning() - if (liquidityFromAvailableBalance == null) { + val initialLspFees = estimateInitialLspFees(availableAmount) + if (initialLspFees == null) { _spendingUiState.update { it.copy(isLoading = false) } return@launch } - val lspBalance1 = maxOf( - liquidityFromAvailableBalance.defaultLspBalanceSat, - liquidityFromAvailableBalance.minLspBalanceSat - ) + val balanceAfterLspFee = availableAmount.safe() - initialLspFees.safe() - val orderFeeFromAvailableAmount = blocktankRepo.estimateOrderFee( - spendingBalanceSats = availableAmount, - receivingBalanceSats = lspBalance1, - ).getOrNull() + estimateFinalMaxSendAmount(availableAmount, balanceAfterLspFee) + } + } - if (orderFeeFromAvailableAmount == null) { - _spendingUiState.update { it.copy(isLoading = false) } - return@launch - } + private suspend fun awaitNodeRunning() { + withTimeoutOrNull(1.minutes) { + isNodeRunning.first { it } + } + } - val lspFeesFromAvailableAmount = - orderFeeFromAvailableAmount.networkFeeSat.safe() + orderFeeFromAvailableAmount.serviceFeeSat.safe() - val balanceAfterLspFee = availableAmount.safe() - lspFeesFromAvailableAmount.safe() + private suspend fun estimateInitialLspFees(availableAmount: ULong): ULong? { + val liquidity = blocktankRepo + .calculateLiquidityOptions(availableAmount) + .getOrNull() ?: return null - // Second step: recalculate with actual clientBalance that order creation will use - val liquidityAfterLspFee = blocktankRepo.calculateLiquidityOptions(balanceAfterLspFee).getOrNull() - if (liquidityAfterLspFee == null || liquidityAfterLspFee.maxLspBalanceSat == 0uL) { - _spendingUiState.update { it.copy(isLoading = false, maxAllowedToSend = 0) } - return@launch - } - val receivingAmountAfterFee = maxOf( - liquidityAfterLspFee.defaultLspBalanceSat, - liquidityAfterLspFee.minLspBalanceSat - ) + val lspBalance = maxOf(liquidity.defaultLspBalanceSat, liquidity.minLspBalanceSat) - blocktankRepo.estimateOrderFee( - spendingBalanceSats = balanceAfterLspFee, - receivingBalanceSats = receivingAmountAfterFee, - ).onSuccess { estimate -> - maxLspFee = estimate.feeSat - val lspFees = estimate.networkFeeSat.safe() + estimate.serviceFeeSat.safe() - val maxClientBalance = availableAmount.safe() - lspFees.safe() + val orderFee = blocktankRepo.estimateOrderFee( + spendingBalanceSats = availableAmount, + receivingBalanceSats = lspBalance, + ).getOrNull() ?: return null - _spendingUiState.update { - it.copy( - maxAllowedToSend = min( - liquidityAfterLspFee.maxClientBalanceSat.toLong(), - maxClientBalance.toLong() - ), - isLoading = false, - balanceAfterFee = availableAmount.toLong(), - ) - } - }.onFailure { exception -> - _spendingUiState.update { it.copy(isLoading = false) } - Logger.error("Failure", exception, context = TAG) - setTransferEffect(TransferEffect.ToastException(exception)) + return orderFee.networkFeeSat.safe() + orderFee.serviceFeeSat.safe() + } + + private suspend fun estimateFinalMaxSendAmount( + availableAmount: ULong, + balanceAfterLspFee: ULong, + ) { + val liquidity = blocktankRepo.calculateLiquidityOptions(balanceAfterLspFee).getOrNull() + if (liquidity == null || liquidity.maxLspBalanceSat == 0uL) { + _spendingUiState.update { it.copy(isLoading = false, maxAllowedToSend = 0) } + return + } + + val receivingAmount = maxOf(liquidity.defaultLspBalanceSat, liquidity.minLspBalanceSat) + + blocktankRepo.estimateOrderFee( + spendingBalanceSats = balanceAfterLspFee, + receivingBalanceSats = receivingAmount, + ).onSuccess { estimate -> + maxLspFee = estimate.feeSat + val lspFees = estimate.networkFeeSat.safe() + estimate.serviceFeeSat.safe() + val maxClientBalance = availableAmount.safe() - lspFees.safe() + + _spendingUiState.update { + it.copy( + maxAllowedToSend = min( + liquidity.maxClientBalanceSat.toLong(), + maxClientBalance.toLong() + ), + isLoading = false, + balanceAfterFee = availableAmount.toLong(), + ) } + }.onFailure { + _spendingUiState.update { it.copy(isLoading = false) } + Logger.error("Failure", it, context = TAG) + setTransferEffect(TransferEffect.ToastException(it)) } }