From 3bcd1d783fc831f90487e0c506b6ddd1fbb23b2c Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 24 Jun 2026 19:40:31 +0200 Subject: [PATCH 1/3] fix: exclude orphan coop close balances --- .../to/bitkit/repositories/TransferRepo.kt | 55 +++++++++------ .../usecases/DeriveBalanceStateUseCase.kt | 32 ++++++++- .../bitkit/repositories/TransferRepoTest.kt | 30 ++++++-- .../usecases/DeriveBalanceStateUseCaseTest.kt | 68 ++++++++++++++++++- changelog.d/next/1041.fixed.md | 1 + 5 files changed, 158 insertions(+), 28 deletions(-) create mode 100644 changelog.d/next/1041.fixed.md diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index 584066edf..44464bd16 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -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 @@ -161,26 +162,7 @@ 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 -> @@ -188,6 +170,39 @@ class TransferRepo @Inject constructor( } } + private suspend fun settleToSavingsTransfers( + activeTransfers: List, + channels: List, + 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?, diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index c958cd837..6c1ba2574 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -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 @@ -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, @@ -156,6 +160,32 @@ class DeriveBalanceStateUseCase @Inject constructor( return amount } + private suspend fun getOrphanCoopCloseSats( + transfers: List, + channels: List, + balanceDetails: BalanceDetails, + ): ULong { + val channelIds = channels.map { it.channelId }.toSet() + val transferChannelIds = mutableSetOf() + for (transfer in transfers) { + transferRepo.resolveChannelIdForTransfer(transfer, channels)?.let { transferChannelIds.add(it) } + } + + var amount = 0uL + val claimableBalances = balanceDetails.lightningBalances + .filterIsInstance() + 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 diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index 68104ccb9..039f52c70 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -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 @@ -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, @@ -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 @@ -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() diff --git a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt index a5ad9620f..74274690a 100644 --- a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt @@ -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 { + 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) @@ -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( diff --git a/changelog.d/next/1041.fixed.md b/changelog.d/next/1041.fixed.md new file mode 100644 index 000000000..0505e35b5 --- /dev/null +++ b/changelog.d/next/1041.fixed.md @@ -0,0 +1 @@ +Spending balances no longer briefly include cooperatively closed channel funds after opening a new channel. From 2a140dfd9778a20959a6b96c6653cf4fb9c19f60 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 24 Jun 2026 19:41:49 +0200 Subject: [PATCH 2/3] chore: rename changelog fragment --- changelog.d/next/{1041.fixed.md => 1043.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{1041.fixed.md => 1043.fixed.md} (100%) diff --git a/changelog.d/next/1041.fixed.md b/changelog.d/next/1043.fixed.md similarity index 100% rename from changelog.d/next/1041.fixed.md rename to changelog.d/next/1043.fixed.md From 76f51328d166cde3dad775a499b309da4798ad6f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 24 Jun 2026 21:06:31 +0200 Subject: [PATCH 3/3] fix: scope orphan transfer lookup --- .../main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index 6c1ba2574..24f1e79ae 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -167,7 +167,7 @@ class DeriveBalanceStateUseCase @Inject constructor( ): ULong { val channelIds = channels.map { it.channelId }.toSet() val transferChannelIds = mutableSetOf() - for (transfer in transfers) { + for (transfer in transfers.filter { it.type.isToSavings() }) { transferRepo.resolveChannelIdForTransfer(transfer, channels)?.let { transferChannelIds.add(it) } }