Skip to content

Commit 3bcd1d7

Browse files
committed
fix: exclude orphan coop close balances
1 parent c25359e commit 3bcd1d7

5 files changed

Lines changed: 158 additions & 28 deletions

File tree

app/src/main/java/to/bitkit/repositories/TransferRepo.kt

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.Flow
1010
import kotlinx.coroutines.flow.combine
1111
import kotlinx.coroutines.flow.first
1212
import kotlinx.coroutines.withContext
13+
import org.lightningdevkit.ldknode.BalanceDetails
1314
import org.lightningdevkit.ldknode.ChannelDetails
1415
import org.lightningdevkit.ldknode.PendingSweepBalance
1516
import to.bitkit.data.dao.TransferDao
@@ -161,33 +162,47 @@ class TransferRepo @Inject constructor(
161162
}
162163
}
163164

164-
val toSavings = activeTransfers.filter { it.type.isToSavings() }
165-
166-
for (transfer in toSavings) {
167-
val channelId = resolveChannelIdForTransfer(transfer, channels)
168-
val hasBalance = balances?.lightningBalances?.any {
169-
it.channelId() == channelId
170-
} ?: false
171-
172-
if (!hasBalance) {
173-
if (transfer.type == TransferType.FORCE_CLOSE) {
174-
settleForceClose(transfer, channelId, balances?.pendingBalancesFromChannelClosures)
175-
} else {
176-
markSettled(transfer.id)
177-
Logger.debug(
178-
"Channel $channelId balance swept, settled transfer: ${transfer.id}",
179-
context = TAG
180-
)
181-
}
182-
}
183-
}
165+
settleToSavingsTransfers(activeTransfers, channels, balances)
184166
}.onSuccess {
185167
Logger.verbose("syncTransferStates completed", context = TAG)
186168
}.onFailure { e ->
187169
Logger.error("syncTransferStates error", e, context = TAG)
188170
}
189171
}
190172

173+
private suspend fun settleToSavingsTransfers(
174+
activeTransfers: List<TransferEntity>,
175+
channels: List<ChannelDetails>,
176+
balances: BalanceDetails?,
177+
) {
178+
val toSavings = activeTransfers.filter { it.type.isToSavings() }
179+
if (toSavings.isEmpty()) return
180+
181+
val balanceDetails = balances ?: run {
182+
Logger.debug("Skipped settling to-savings transfers because balances are unavailable", context = TAG)
183+
return
184+
}
185+
186+
for (transfer in toSavings) {
187+
val channelId = resolveChannelIdForTransfer(transfer, channels)
188+
val hasBalance = balanceDetails.lightningBalances.any {
189+
it.channelId() == channelId
190+
}
191+
192+
if (!hasBalance) {
193+
if (transfer.type == TransferType.FORCE_CLOSE) {
194+
settleForceClose(transfer, channelId, balanceDetails.pendingBalancesFromChannelClosures)
195+
} else {
196+
markSettled(transfer.id)
197+
Logger.debug(
198+
"Channel $channelId balance swept, settled transfer: ${transfer.id}",
199+
context = TAG
200+
)
201+
}
202+
}
203+
}
204+
}
205+
191206
private suspend fun settleForceClose(
192207
transfer: TransferEntity,
193208
channelId: String?,

app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import kotlinx.coroutines.CoroutineDispatcher
44
import kotlinx.coroutines.flow.first
55
import kotlinx.coroutines.withContext
66
import org.lightningdevkit.ldknode.BalanceDetails
7+
import org.lightningdevkit.ldknode.BalanceSource
78
import org.lightningdevkit.ldknode.ChannelDetails
9+
import org.lightningdevkit.ldknode.LightningBalance
810
import to.bitkit.data.SettingsStore
911
import to.bitkit.data.entities.TransferEntity
1012
import to.bitkit.di.BgDispatcher
@@ -43,12 +45,14 @@ class DeriveBalanceStateUseCase @Inject constructor(
4345

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

4851
val totalOnchainSats = balanceDetails.totalOnchainBalanceSats
4952
val channelFundableBalance = getMaxChannelFundableAmount(lightningRepo.getChannelFundableBalance())
5053
val afterPendingChannels = balanceDetails.totalLightningBalanceSats.safe() - pendingChannelsSats.safe()
51-
val totalLightningSats = afterPendingChannels.safe() - toSavingsAmount.safe()
54+
val afterClosingChannels = afterPendingChannels.safe() - toSavingsAmount.safe()
55+
val totalLightningSats = afterClosingChannels.safe() - orphanCoopCloseSats.safe()
5256

5357
val balanceState = BalanceState(
5458
totalOnchainSats = totalOnchainSats,
@@ -156,6 +160,32 @@ class DeriveBalanceStateUseCase @Inject constructor(
156160
return amount
157161
}
158162

163+
private suspend fun getOrphanCoopCloseSats(
164+
transfers: List<TransferEntity>,
165+
channels: List<ChannelDetails>,
166+
balanceDetails: BalanceDetails,
167+
): ULong {
168+
val channelIds = channels.map { it.channelId }.toSet()
169+
val transferChannelIds = mutableSetOf<String>()
170+
for (transfer in transfers) {
171+
transferRepo.resolveChannelIdForTransfer(transfer, channels)?.let { transferChannelIds.add(it) }
172+
}
173+
174+
var amount = 0uL
175+
val claimableBalances = balanceDetails.lightningBalances
176+
.filterIsInstance<LightningBalance.ClaimableAwaitingConfirmations>()
177+
for (balance in claimableBalances) {
178+
val isOrphanCoopClose = balance.source == BalanceSource.COOP_CLOSE &&
179+
balance.channelId !in channelIds &&
180+
balance.channelId !in transferChannelIds
181+
182+
if (isOrphanCoopClose) {
183+
amount = amount.safe() + balance.amountSatoshis.safe()
184+
}
185+
}
186+
return amount
187+
}
188+
159189
private suspend fun getMaxSendAmount(balanceDetails: BalanceDetails): ULong {
160190
val spendableOnchainSats = balanceDetails.spendableOnchainBalanceSats
161191
if (spendableOnchainSats == 0uL) return 0u

app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import to.bitkit.models.TransferType
3232
import to.bitkit.services.ActivityService
3333
import to.bitkit.services.CoreService
3434
import to.bitkit.test.BaseUnitTest
35+
import to.bitkit.utils.AppError
3536
import kotlin.test.assertEquals
3637
import kotlin.test.assertNotNull
3738
import kotlin.test.assertNull
@@ -413,8 +414,7 @@ class TransferRepoTest : BaseUnitTest() {
413414
}
414415

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

427427
whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer)))
428428
whenever(lightningRepo.getChannels()).thenReturn(emptyList())
429-
whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.failure(Exception("Error")))
430-
whenever(transferDao.markSettled(any(), any())).thenReturn(Unit)
429+
whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.failure(AppError("Balances unavailable")))
431430

432431
val result = sut.syncTransferStates()
433432

434433
assertTrue(result.isSuccess)
435-
verify(transferDao).markSettled(eq(ID_TRANSFER), eq(settledAt))
434+
verify(transferDao, never()).markSettled(any(), any())
436435
}
437436

438437
@Test
@@ -543,6 +542,27 @@ class TransferRepoTest : BaseUnitTest() {
543542
verify(transferDao, never()).markSettled(any(), any())
544543
}
545544

545+
@Test
546+
fun `syncTransferStates does not settle COOP_CLOSE when balances are unavailable`() = test {
547+
val transfer = TransferEntity(
548+
id = ID_TRANSFER,
549+
type = TransferType.COOP_CLOSE,
550+
amountSats = 75000L,
551+
channelId = ID_CHANNEL,
552+
isSettled = false,
553+
createdAt = 1000L,
554+
)
555+
556+
whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer)))
557+
whenever(lightningRepo.getChannels()).thenReturn(emptyList())
558+
whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.failure(AppError("Balances unavailable")))
559+
560+
val result = sut.syncTransferStates()
561+
562+
assertTrue(result.isSuccess)
563+
verify(transferDao, never()).markSettled(any(), any())
564+
}
565+
546566
@Test
547567
fun `syncTransferStates settles COOP_CLOSE when LDK balance is gone`() = test {
548568
val settledAt = setupClockNowMock()

app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,66 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() {
400400
)
401401
}
402402

403+
@Test
404+
fun `should subtract orphan coop close balance from lightning while keeping new channel balance`() = test {
405+
val closedChannelId = "closed-channel-id"
406+
val newChannelId = "new-channel-id"
407+
val closedChannelSats = 1_531_123uL
408+
val newChannelSats = 62_158uL
409+
val orphanClosingBalance = newClosingChannelBalance(closedChannelId, closedChannelSats)
410+
val newChannelBalance = newChannelBalance(newChannelId, newChannelSats)
411+
412+
val balance = newBalanceDetails().copy(
413+
totalOnchainBalanceSats = closedChannelSats,
414+
totalLightningBalanceSats = 1_593_281uL,
415+
lightningBalances = listOf(orphanClosingBalance, newChannelBalance),
416+
)
417+
whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balance))
418+
419+
val newChannel = mock<ChannelDetails> {
420+
on { channelId } doReturn newChannelId
421+
on { isChannelReady } doReturn true
422+
}
423+
424+
whenever(lightningRepo.getChannels()).thenReturn(listOf(newChannel))
425+
whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList()))
426+
427+
val result = sut()
428+
429+
assertTrue(result.isSuccess)
430+
val balanceState = result.getOrThrow()
431+
assertEquals(closedChannelSats, balanceState.totalOnchainSats)
432+
assertEquals(newChannelSats, balanceState.totalLightningSats)
433+
assertEquals(0uL, balanceState.balanceInTransferToSavings)
434+
assertEquals(0uL, balanceState.balanceInTransferToSpending)
435+
}
436+
437+
@Test
438+
fun `should not subtract orphan non coop close balance from lightning`() = test {
439+
val channelId = "force-closed-channel-id"
440+
val amountSats = 40_000uL
441+
val closingChannelBalance = newClosingChannelBalance(
442+
id = channelId,
443+
sats = amountSats,
444+
source = BalanceSource.COUNTERPARTY_FORCE_CLOSED,
445+
)
446+
447+
val balance = newBalanceDetails().copy(
448+
lightningBalances = listOf(closingChannelBalance),
449+
totalLightningBalanceSats = amountSats,
450+
)
451+
whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balance))
452+
453+
whenever(lightningRepo.getChannels()).thenReturn(emptyList())
454+
whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList()))
455+
456+
val result = sut()
457+
458+
assertTrue(result.isSuccess)
459+
val balanceState = result.getOrThrow()
460+
assertEquals(amountSats, balanceState.totalLightningSats)
461+
}
462+
403463
@Test
404464
fun `should calculate zero max send onchain when spendable balance is zero`() = test {
405465
val balance = newBalanceDetails().copy(totalOnchainBalanceSats = 50_000u)
@@ -554,12 +614,16 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() {
554614
inboundHtlcRoundedMsat = 0u,
555615
)
556616

557-
private fun newClosingChannelBalance(id: String, sats: ULong) = LightningBalance.ClaimableAwaitingConfirmations(
617+
private fun newClosingChannelBalance(
618+
id: String,
619+
sats: ULong,
620+
source: BalanceSource = BalanceSource.COOP_CLOSE,
621+
) = LightningBalance.ClaimableAwaitingConfirmations(
558622
channelId = id,
559623
counterpartyNodeId = "node-id",
560624
amountSatoshis = sats,
561625
confirmationHeight = 344u,
562-
source = BalanceSource.COOP_CLOSE,
626+
source = source,
563627
)
564628

565629
private fun newTransferEntity(

changelog.d/next/1041.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Spending balances no longer briefly include cooperatively closed channel funds after opening a new channel.

0 commit comments

Comments
 (0)