Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 50 additions & 13 deletions app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -194,13 +195,26 @@ 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,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fee Estimation Uses Wrong Amount

The calculateTotalFee call on line 201-205 estimates the on-chain mining fee for the wrong transaction shape, which can cause incorrect dust change detection.

The Problem:

The code passes amountSats = spendableBalance (the full wallet balance) to estimate the fee. When the amount equals the full balance, the fee estimator constructs a send-all transaction with no change output (1 output). However, the change formula on line 207 models a transaction sending order.feeSat (less than the full balance), which will have a change output (2 outputs).

A transaction with a change output has a higher fee because the extra output adds weight (a P2WPKH output is ~31 vbytes, P2TR ~43 vbytes). At typical fee rates, this is a few hundred sats difference.

Why This Matters:

Because txFee is underestimated, expectedChange is overestimated by the cost of one change output. This creates a window where the actual change would be dust (< 546 sats), but the code computes expectedChange >= 546, failing to set shouldUseSendAll = true. The transaction then attempts a regular send with a dust change output, which can fail.

Example:

Suppose:

  • spendableBalance = 100,000 sats
  • order.feeSat = 98,200 sats
  • Fee for send-all (1 output): ~1100 sats
  • Fee for regular send (2 outputs): ~1410 sats

Code computes: expectedChange = 100,000 - 98,200 - 1,100 = 700 (above 546, so shouldUseSendAll = false)

Correct: expectedChange = 100,000 - 98,200 - 1,410 = 390 (below 546, should use send-all!)

The transaction would attempt a regular send with a 390-sat change output, which is dust and would fail.

The Fix:

Suggested change
amountSats = spendableBalance,
val txFee = lightningRepo.calculateTotalFee(
amountSats = order.feeSat,
address = address,
speed = speed,
).getOrElse { 0uL }

This ensures the fee is estimated for the same transaction shape (sending order.feeSat with a change output) that the change formula models.

Reference:

lightningRepo.lightningState.value.balances?.spendableOnchainBalanceSats ?: 0uL
val txFee = lightningRepo.calculateTotalFee(
amountSats = spendableBalance,
address = address,
speed = speed,
).getOrElse { 0uL }

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,
sats = order.feeSat,
speed = speed,
isTransfer = true,
channelId = order.channel?.shortChannelId,
isMaxAmount = shouldUseSendAll,
)
.onSuccess { txId ->
cacheStore.addPaidOrder(orderId = order.id, txId = txId)
Expand Down Expand Up @@ -297,30 +311,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))
}
}
Expand Down
Loading