Skip to content
Open
Show file tree
Hide file tree
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
55 changes: 35 additions & 20 deletions app/src/main/java/to/bitkit/repositories/TransferRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.lightningdevkit.ldknode.BalanceDetails
import org.lightningdevkit.ldknode.ChannelDetails
import org.lightningdevkit.ldknode.PendingSweepBalance
import to.bitkit.data.dao.TransferDao
Expand Down Expand Up @@ -161,33 +162,47 @@ class TransferRepo @Inject constructor(
}
}

val toSavings = activeTransfers.filter { it.type.isToSavings() }

for (transfer in toSavings) {
val channelId = resolveChannelIdForTransfer(transfer, channels)
val hasBalance = balances?.lightningBalances?.any {
it.channelId() == channelId
} ?: false

if (!hasBalance) {
if (transfer.type == TransferType.FORCE_CLOSE) {
settleForceClose(transfer, channelId, balances?.pendingBalancesFromChannelClosures)
} else {
markSettled(transfer.id)
Logger.debug(
"Channel $channelId balance swept, settled transfer: ${transfer.id}",
context = TAG
)
}
}
}
settleToSavingsTransfers(activeTransfers, channels, balances)
}.onSuccess {
Logger.verbose("syncTransferStates completed", context = TAG)
}.onFailure { e ->
Logger.error("syncTransferStates error", e, context = TAG)
}
}

private suspend fun settleToSavingsTransfers(
activeTransfers: List<TransferEntity>,
channels: List<ChannelDetails>,
balances: BalanceDetails?,
) {
val toSavings = activeTransfers.filter { it.type.isToSavings() }
if (toSavings.isEmpty()) return

val balanceDetails = balances ?: run {
Logger.debug("Skipped settling to-savings transfers because balances are unavailable", context = TAG)
return
}

for (transfer in toSavings) {
val channelId = resolveChannelIdForTransfer(transfer, channels)
val hasBalance = balanceDetails.lightningBalances.any {
it.channelId() == channelId
}

if (!hasBalance) {
if (transfer.type == TransferType.FORCE_CLOSE) {
settleForceClose(transfer, channelId, balanceDetails.pendingBalancesFromChannelClosures)
} else {
markSettled(transfer.id)
Logger.debug(
"Channel $channelId balance swept, settled transfer: ${transfer.id}",
context = TAG
)
}
}
}
}

private suspend fun settleForceClose(
transfer: TransferEntity,
channelId: String?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.lightningdevkit.ldknode.BalanceDetails
import org.lightningdevkit.ldknode.BalanceSource
import org.lightningdevkit.ldknode.ChannelDetails
import org.lightningdevkit.ldknode.LightningBalance
import to.bitkit.data.SettingsStore
import to.bitkit.data.entities.TransferEntity
import to.bitkit.di.BgDispatcher
Expand Down Expand Up @@ -43,12 +45,14 @@ class DeriveBalanceStateUseCase @Inject constructor(

val toSavingsAmount = getTransferToSavingsSats(activeTransfers, channels, balanceDetails)
val coopCloseSavingsSats = getCoopCloseTransferSats(activeTransfers, channels, balanceDetails)
val orphanCoopCloseSats = getOrphanCoopCloseSats(activeTransfers, channels, balanceDetails)
val toSpendingAmount = paidOrdersSats.safe() + pendingChannelsSats.safe()

val totalOnchainSats = balanceDetails.totalOnchainBalanceSats
val channelFundableBalance = getMaxChannelFundableAmount(lightningRepo.getChannelFundableBalance())
val afterPendingChannels = balanceDetails.totalLightningBalanceSats.safe() - pendingChannelsSats.safe()
val totalLightningSats = afterPendingChannels.safe() - toSavingsAmount.safe()
val afterClosingChannels = afterPendingChannels.safe() - toSavingsAmount.safe()
val totalLightningSats = afterClosingChannels.safe() - orphanCoopCloseSats.safe()

val balanceState = BalanceState(
totalOnchainSats = totalOnchainSats,
Expand Down Expand Up @@ -156,6 +160,32 @@ class DeriveBalanceStateUseCase @Inject constructor(
return amount
}

private suspend fun getOrphanCoopCloseSats(
transfers: List<TransferEntity>,
channels: List<ChannelDetails>,
balanceDetails: BalanceDetails,
): ULong {
val channelIds = channels.map { it.channelId }.toSet()
val transferChannelIds = mutableSetOf<String>()
for (transfer in transfers.filter { it.type.isToSavings() }) {
transferRepo.resolveChannelIdForTransfer(transfer, channels)?.let { transferChannelIds.add(it) }
}

var amount = 0uL
val claimableBalances = balanceDetails.lightningBalances
.filterIsInstance<LightningBalance.ClaimableAwaitingConfirmations>()
for (balance in claimableBalances) {
val isOrphanCoopClose = balance.source == BalanceSource.COOP_CLOSE &&
balance.channelId !in channelIds &&
balance.channelId !in transferChannelIds

if (isOrphanCoopClose) {
amount = amount.safe() + balance.amountSatoshis.safe()
}
}
return amount
}

private suspend fun getMaxSendAmount(balanceDetails: BalanceDetails): ULong {
val spendableOnchainSats = balanceDetails.spendableOnchainBalanceSats
if (spendableOnchainSats == 0uL) return 0u
Expand Down
30 changes: 25 additions & 5 deletions app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import to.bitkit.models.TransferType
import to.bitkit.services.ActivityService
import to.bitkit.services.CoreService
import to.bitkit.test.BaseUnitTest
import to.bitkit.utils.AppError
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
Expand Down Expand Up @@ -413,8 +414,7 @@ class TransferRepoTest : BaseUnitTest() {
}

@Test
fun `syncTransferStates settles TO_SAVINGS transfer when balances is null`() = test {
val settledAt = setupClockNowMock()
fun `syncTransferStates does not settle TO_SAVINGS transfer when balances are unavailable`() = test {
val transfer = TransferEntity(
id = ID_TRANSFER,
type = TransferType.TO_SAVINGS,
Expand All @@ -426,13 +426,12 @@ class TransferRepoTest : BaseUnitTest() {

whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer)))
whenever(lightningRepo.getChannels()).thenReturn(emptyList())
whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.failure(Exception("Error")))
whenever(transferDao.markSettled(any(), any())).thenReturn(Unit)
whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.failure(AppError("Balances unavailable")))

val result = sut.syncTransferStates()

assertTrue(result.isSuccess)
verify(transferDao).markSettled(eq(ID_TRANSFER), eq(settledAt))
verify(transferDao, never()).markSettled(any(), any())
}

@Test
Expand Down Expand Up @@ -543,6 +542,27 @@ class TransferRepoTest : BaseUnitTest() {
verify(transferDao, never()).markSettled(any(), any())
}

@Test
fun `syncTransferStates does not settle COOP_CLOSE when balances are unavailable`() = test {
val transfer = TransferEntity(
id = ID_TRANSFER,
type = TransferType.COOP_CLOSE,
amountSats = 75000L,
channelId = ID_CHANNEL,
isSettled = false,
createdAt = 1000L,
)

whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer)))
whenever(lightningRepo.getChannels()).thenReturn(emptyList())
whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.failure(AppError("Balances unavailable")))

val result = sut.syncTransferStates()

assertTrue(result.isSuccess)
verify(transferDao, never()).markSettled(any(), any())
}

@Test
fun `syncTransferStates settles COOP_CLOSE when LDK balance is gone`() = test {
val settledAt = setupClockNowMock()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,66 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() {
)
}

@Test
fun `should subtract orphan coop close balance from lightning while keeping new channel balance`() = test {
val closedChannelId = "closed-channel-id"
val newChannelId = "new-channel-id"
val closedChannelSats = 1_531_123uL
val newChannelSats = 62_158uL
val orphanClosingBalance = newClosingChannelBalance(closedChannelId, closedChannelSats)
val newChannelBalance = newChannelBalance(newChannelId, newChannelSats)

val balance = newBalanceDetails().copy(
totalOnchainBalanceSats = closedChannelSats,
totalLightningBalanceSats = 1_593_281uL,
lightningBalances = listOf(orphanClosingBalance, newChannelBalance),
)
whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balance))

val newChannel = mock<ChannelDetails> {
on { channelId } doReturn newChannelId
on { isChannelReady } doReturn true
}

whenever(lightningRepo.getChannels()).thenReturn(listOf(newChannel))
whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList()))

val result = sut()

assertTrue(result.isSuccess)
val balanceState = result.getOrThrow()
assertEquals(closedChannelSats, balanceState.totalOnchainSats)
assertEquals(newChannelSats, balanceState.totalLightningSats)
assertEquals(0uL, balanceState.balanceInTransferToSavings)
assertEquals(0uL, balanceState.balanceInTransferToSpending)
}

@Test
fun `should not subtract orphan non coop close balance from lightning`() = test {
val channelId = "force-closed-channel-id"
val amountSats = 40_000uL
val closingChannelBalance = newClosingChannelBalance(
id = channelId,
sats = amountSats,
source = BalanceSource.COUNTERPARTY_FORCE_CLOSED,
)

val balance = newBalanceDetails().copy(
lightningBalances = listOf(closingChannelBalance),
totalLightningBalanceSats = amountSats,
)
whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balance))

whenever(lightningRepo.getChannels()).thenReturn(emptyList())
whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList()))

val result = sut()

assertTrue(result.isSuccess)
val balanceState = result.getOrThrow()
assertEquals(amountSats, balanceState.totalLightningSats)
}

@Test
fun `should calculate zero max send onchain when spendable balance is zero`() = test {
val balance = newBalanceDetails().copy(totalOnchainBalanceSats = 50_000u)
Expand Down Expand Up @@ -554,12 +614,16 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() {
inboundHtlcRoundedMsat = 0u,
)

private fun newClosingChannelBalance(id: String, sats: ULong) = LightningBalance.ClaimableAwaitingConfirmations(
private fun newClosingChannelBalance(
id: String,
sats: ULong,
source: BalanceSource = BalanceSource.COOP_CLOSE,
) = LightningBalance.ClaimableAwaitingConfirmations(
channelId = id,
counterpartyNodeId = "node-id",
amountSatoshis = sats,
confirmationHeight = 344u,
source = BalanceSource.COOP_CLOSE,
source = source,
)

private fun newTransferEntity(
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1043.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Spending balances no longer briefly include cooperatively closed channel funds after opening a new channel.
Loading