Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
78 changes: 53 additions & 25 deletions app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,8 @@ class TransferViewModel @Inject constructor(
}

/** Pays for the order and start watching it for state updates */
fun onTransferToSpendingConfirm(order: IBtOrder, speed: TransactionSpeed? = null) {
@Suppress("LongMethod")
fun onTransferToSpendingConfirm(order: IBtOrder, speed: TransactionSpeed = TransactionSpeed.Fast) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
fun onTransferToSpendingConfirm(order: IBtOrder, speed: TransactionSpeed = TransactionSpeed.Fast) {
fun onTransferToSpendingConfirm(order: IBtOrder) {

A custom speed is never set anywhere, could remove this parameter

viewModelScope.launch {
val address = order.payment?.onchain?.address.orEmpty()

Expand All @@ -230,53 +231,70 @@ class TransferViewModel @Inject constructor(

val expectedChange =
spendableBalance.toLong() - order.feeSat.toLong() - sendAllFee.toLong()
// Match iOS: proactive drain only for dust change. Negative change means the drain
// output would underpay the order — never send-all in that case.
val shouldUseSendAll =
expectedChange >= 0 && expectedChange < TRANSFER_SEND_ALL_THRESHOLD_SATS
Comment on lines 236 to 237

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: could apply a small refactor converting this to a range check


val miningFee = if (shouldUseSendAll) {
sendAllFee
} else {
lightningRepo.calculateTotalFee(
amountSats = order.feeSat,
address = address,
speed = speed,
).getOrElse {
Logger.warn("Failed to estimate transfer funding fee", it, context = TAG)
0uL
}
}
val txTotalSats = if (shouldUseSendAll) {
spendableBalance
} else {
order.feeSat.safe() + miningFee.safe()
}
val maxSendable =
if (sendAllFee >= spendableBalance) 0uL else spendableBalance - sendAllFee
Comment on lines +238 to +239

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

use .safe() here. It has this clamp-to-zero internal logic


Logger.debug(
"BT confirm: spendable=$spendableBalance, feeSat=${order.feeSat}, " +
"sendAllFee=$sendAllFee, expectedChange=$expectedChange, sendAll=$shouldUseSendAll",
context = TAG,
)

lightningRepo
suspend fun fund(isMaxAmount: Boolean, txTotalSats: ULong) = lightningRepo
.sendOnChain(
address = address,
sats = order.feeSat,
speed = speed,
isTransfer = true,
channelId = order.channel?.shortChannelId,
isMaxAmount = shouldUseSendAll,
isMaxAmount = isMaxAmount,
)
.onSuccess { txId ->
fundPaidOrder(
order = order,
txId = txId,
txTotalSats = txTotalSats,
preTransferOnchainSats = balanceDetails?.totalOnchainBalanceSats ?: spendableBalance,
preTransferOnchainSats = balanceDetails?.totalOnchainBalanceSats
?: spendableBalance,
)
}
.onFailure { error ->

if (shouldUseSendAll) {
fund(isMaxAmount = true, txTotalSats = spendableBalance)
.onFailure { ToastEventBus.send(it) }
return@launch
}

val miningFee = lightningRepo.calculateTotalFee(
amountSats = order.feeSat,
address = address,
speed = speed,
).getOrElse {
Logger.warn("Failed to estimate transfer funding fee", it, context = TAG)
0uL
}
fund(
isMaxAmount = false,
txTotalSats = order.feeSat.safe() + miningFee.safe(),
).onFailure { error ->
// Match iOS: if fixed send fails (e.g. coin selection), drain only when the
// drain output still covers order.feeSat — never underpay by emptying the wallet.
if (maxSendable >= order.feeSat) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm still checking this logic

Logger.warn(
"Normal transfer funding failed, retrying send-all",
error,
context = TAG,
)
fund(isMaxAmount = true, txTotalSats = spendableBalance)
.onFailure { ToastEventBus.send(it) }
} else {
ToastEventBus.send(error)
}
}
}
}

Expand Down Expand Up @@ -415,10 +433,20 @@ class TransferViewModel @Inject constructor(
viewModelScope.launch {
_spendingUiState.update { it.copy(isLoading = true) }

val availableAmount = walletRepo.balanceState.value.maxSendOnchainSats

awaitNodeRunning()

// Match iOS: size the order against spendable minus fast send-all mining fee so
// max transfer leaves room to fund the order on-chain.
val spendable = walletRepo.balanceState.value.maxSendOnchainSats
val miningFee = lightningRepo.estimateSendAllFee(
speed = TransactionSpeed.Fast,
).getOrElse {
Logger.warn("Failed to estimate transfer mining fee reserve", it, context = TAG)
0uL
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
val availableAmount =
if (miningFee >= spendable) 0uL else spendable - miningFee
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
Comment on lines +448 to +449

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

use .safe() here too


val initialLspFees = estimateInitialLspFees(availableAmount)
if (initialLspFees == null) {
_spendingUiState.update { it.copy(isLoading = false) }
Expand Down
190 changes: 190 additions & 0 deletions app/src/test/java/to/bitkit/viewmodels/TransferViewModelTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import org.junit.Before
import org.junit.Test
import org.lightningdevkit.ldknode.BalanceDetails
import org.lightningdevkit.ldknode.NodeStatus
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
Expand All @@ -51,6 +52,7 @@ import to.bitkit.models.HwFundingSignedTx
import to.bitkit.models.HwFundingTransaction
import to.bitkit.models.HwWallet
import to.bitkit.models.Toast
import to.bitkit.models.TransactionSpeed
import to.bitkit.models.TransferType
import to.bitkit.models.TransportType
import to.bitkit.models.safe
Expand Down Expand Up @@ -103,6 +105,9 @@ class TransferViewModelTest : BaseUnitTest() {
whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState(nodeStatus = nodeStatus)))
whenever(walletRepo.balanceState).thenReturn(balanceState)
whenever(blocktankRepo.blocktankState).thenReturn(blocktankState)
// Default: no mining-fee reserve so existing limit tests keep their balances.
whenever { lightningRepo.estimateSendAllFee(anyOrNull(), anyOrNull(), anyOrNull()) }
.thenReturn(Result.success(0uL))

sut = TransferViewModel(
context = context,
Expand Down Expand Up @@ -169,6 +174,33 @@ 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
balanceState.value = BalanceState(maxSendOnchainSats = 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 `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.
Expand Down Expand Up @@ -300,6 +332,136 @@ class TransferViewModelTest : BaseUnitTest() {
assertEquals(999uL, sut.spendingUiState.value.hwMiningFeeSats)
}

@Test
fun `onTransferToSpendingConfirm uses send-all when expected change is dust`() = test {
val order = previewBtOrder(feeSat = 99_000uL)
stubSpendableBalances(spendable = 100_000u)
whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull())).thenReturn(Result.success(500uL))
stubSendOnChainSuccess()

sut.onTransferToSpendingConfirm(order)
advanceUntilIdle()

verify(lightningRepo).sendOnChain(
address = eq(order.payment?.onchain?.address.orEmpty()),
sats = eq(order.feeSat),
speed = eq(TransactionSpeed.Fast),
utxosToSpend = anyOrNull(),
feeRates = anyOrNull(),
isTransfer = eq(true),
channelId = anyOrNull(),
isMaxAmount = eq(true),
tags = any(),
)
verify(lightningRepo, never()).calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull())
verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID))
}

@Test
fun `onTransferToSpendingConfirm retries send-all when fixed send fails but drain still covers order`() =
test {
val order = previewBtOrder(feeSat = 98_000uL)
stubSpendableBalances(spendable = 100_000u)
whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull()))
.thenReturn(Result.success(1_000uL))
whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull()))
.thenReturn(Result.success(1_000uL))
whenever(
lightningRepo.sendOnChain(
any(),
any(),
any(),
anyOrNull(),
anyOrNull(),
any(),
anyOrNull(),
any(),
any(),
),
).thenReturn(
Result.failure(AppError("Coin selection failed")),
Result.success(TXID),
)

sut.onTransferToSpendingConfirm(order)
advanceUntilIdle()

verify(lightningRepo).sendOnChain(
address = eq(order.payment?.onchain?.address.orEmpty()),
sats = eq(order.feeSat),
speed = eq(TransactionSpeed.Fast),
utxosToSpend = anyOrNull(),
feeRates = anyOrNull(),
isTransfer = eq(true),
channelId = anyOrNull(),
isMaxAmount = eq(false),
tags = any(),
)
verify(lightningRepo).sendOnChain(
address = eq(order.payment?.onchain?.address.orEmpty()),
sats = eq(order.feeSat),
speed = eq(TransactionSpeed.Fast),
utxosToSpend = anyOrNull(),
feeRates = anyOrNull(),
isTransfer = eq(true),
channelId = anyOrNull(),
isMaxAmount = eq(true),
tags = any(),
)
verify(cacheStore).addPaidOrder(eq(order.id), eq(TXID))
}

@Test
fun `onTransferToSpendingConfirm does not drain when send-all would underpay the order`() = test {
// CI multi_address_2 numbers: drain fee leaves maxSendable < feeSat.
val order = previewBtOrder(feeSat = 99_457uL)
stubSpendableBalances(spendable = 100_000u)
whenever(lightningRepo.estimateSendAllFee(any(), any(), anyOrNull()))
.thenReturn(Result.success(1_058uL))
whenever(lightningRepo.calculateTotalFee(any(), any(), any(), anyOrNull(), anyOrNull()))
.thenReturn(Result.success(1_000uL))
whenever(
lightningRepo.sendOnChain(
any(),
any(),
any(),
anyOrNull(),
anyOrNull(),
any(),
anyOrNull(),
any(),
any(),
),
).thenReturn(Result.failure(AppError("Coin selection failed")))

sut.onTransferToSpendingConfirm(order)
advanceUntilIdle()

verify(lightningRepo, times(1)).sendOnChain(
address = eq(order.payment?.onchain?.address.orEmpty()),
sats = eq(order.feeSat),
speed = eq(TransactionSpeed.Fast),
utxosToSpend = anyOrNull(),
feeRates = anyOrNull(),
isTransfer = eq(true),
channelId = anyOrNull(),
isMaxAmount = eq(false),
tags = any(),
)
verify(lightningRepo, never()).sendOnChain(
address = any(),
sats = any(),
speed = any(),
utxosToSpend = anyOrNull(),
feeRates = anyOrNull(),
isTransfer = any(),
channelId = anyOrNull(),
isMaxAmount = eq(true),
tags = any(),
)
verify(cacheStore, never()).addPaidOrder(any(), any())
}

@Test
fun `onTransferToSpendingHwConfirm signs the funding send and records the paid order`() = test {
val order = previewBtOrder()
Expand Down Expand Up @@ -1004,6 +1166,34 @@ class TransferViewModelTest : BaseUnitTest() {
return mock<IBtInfo>().also { whenever(it.options).thenReturn(options) }
}

private suspend fun stubSpendableBalances(spendable: ULong) {
val balances = BalanceDetails(
totalOnchainBalanceSats = spendable,
spendableOnchainBalanceSats = spendable,
totalAnchorChannelsReserveSats = 0u,
totalLightningBalanceSats = 0u,
lightningBalances = emptyList(),
pendingBalancesFromChannelClosures = emptyList(),
)
whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances))
}

private suspend fun stubSendOnChainSuccess() {
whenever(
lightningRepo.sendOnChain(
any(),
any(),
any(),
anyOrNull(),
anyOrNull(),
any(),
anyOrNull(),
any(),
any(),
),
).thenReturn(Result.success(TXID))
}

private companion object {
const val ON_CHAIN_BALANCE = 10_000_000uL
const val LSP_MAX_CLIENT_BALANCE = 1_766_193uL
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1091.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
Loading