Skip to content

Commit 3d9855d

Browse files
committed
fix: exclude orphan coop close balances
1 parent e156d8d commit 3d9855d

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
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.Flow
99
import kotlinx.coroutines.flow.combine
1010
import kotlinx.coroutines.flow.first
1111
import kotlinx.coroutines.withContext
12+
import org.lightningdevkit.ldknode.BalanceDetails
1213
import org.lightningdevkit.ldknode.ChannelDetails
1314
import org.lightningdevkit.ldknode.PendingSweepBalance
1415
import to.bitkit.data.dao.TransferDao
@@ -124,33 +125,47 @@ class TransferRepo @Inject constructor(
124125
}
125126
}
126127

127-
val toSavings = activeTransfers.filter { it.type.isToSavings() }
128-
129-
for (transfer in toSavings) {
130-
val channelId = resolveChannelIdForTransfer(transfer, channels)
131-
val hasBalance = balances?.lightningBalances?.any {
132-
it.channelId() == channelId
133-
} ?: false
134-
135-
if (!hasBalance) {
136-
if (transfer.type == TransferType.FORCE_CLOSE) {
137-
settleForceClose(transfer, channelId, balances?.pendingBalancesFromChannelClosures)
138-
} else {
139-
markSettled(transfer.id)
140-
Logger.debug(
141-
"Channel $channelId balance swept, settled transfer: ${transfer.id}",
142-
context = TAG
143-
)
144-
}
145-
}
146-
}
128+
settleToSavingsTransfers(activeTransfers, channels, balances)
147129
}.onSuccess {
148130
Logger.verbose("syncTransferStates completed", context = TAG)
149131
}.onFailure { e ->
150132
Logger.error("syncTransferStates error", e, context = TAG)
151133
}
152134
}
153135

136+
private suspend fun settleToSavingsTransfers(
137+
activeTransfers: List<TransferEntity>,
138+
channels: List<ChannelDetails>,
139+
balances: BalanceDetails?,
140+
) {
141+
val toSavings = activeTransfers.filter { it.type.isToSavings() }
142+
if (toSavings.isEmpty()) return
143+
144+
val balanceDetails = balances ?: run {
145+
Logger.debug("Skipped settling to-savings transfers because balances are unavailable", context = TAG)
146+
return
147+
}
148+
149+
for (transfer in toSavings) {
150+
val channelId = resolveChannelIdForTransfer(transfer, channels)
151+
val hasBalance = balanceDetails.lightningBalances.any {
152+
it.channelId() == channelId
153+
}
154+
155+
if (!hasBalance) {
156+
if (transfer.type == TransferType.FORCE_CLOSE) {
157+
settleForceClose(transfer, channelId, balanceDetails.pendingBalancesFromChannelClosures)
158+
} else {
159+
markSettled(transfer.id)
160+
Logger.debug(
161+
"Channel $channelId balance swept, settled transfer: ${transfer.id}",
162+
context = TAG
163+
)
164+
}
165+
}
166+
}
167+
}
168+
154169
private suspend fun settleForceClose(
155170
transfer: TransferEntity,
156171
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
@@ -42,12 +44,14 @@ class DeriveBalanceStateUseCase @Inject constructor(
4244

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

4750
val totalOnchainSats = balanceDetails.totalOnchainBalanceSats
4851
val channelFundableBalance = getMaxChannelFundableAmount(lightningRepo.getChannelFundableBalance())
4952
val afterPendingChannels = balanceDetails.totalLightningBalanceSats.safe() - pendingChannelsSats.safe()
50-
val totalLightningSats = afterPendingChannels.safe() - toSavingsAmount.safe()
53+
val afterClosingChannels = afterPendingChannels.safe() - toSavingsAmount.safe()
54+
val totalLightningSats = afterClosingChannels.safe() - orphanCoopCloseSats.safe()
5155

5256
val balanceState = BalanceState(
5357
totalOnchainSats = totalOnchainSats,
@@ -139,6 +143,32 @@ class DeriveBalanceStateUseCase @Inject constructor(
139143
return amount
140144
}
141145

146+
private suspend fun getOrphanCoopCloseSats(
147+
transfers: List<TransferEntity>,
148+
channels: List<ChannelDetails>,
149+
balanceDetails: BalanceDetails,
150+
): ULong {
151+
val channelIds = channels.map { it.channelId }.toSet()
152+
val transferChannelIds = mutableSetOf<String>()
153+
for (transfer in transfers) {
154+
transferRepo.resolveChannelIdForTransfer(transfer, channels)?.let { transferChannelIds.add(it) }
155+
}
156+
157+
var amount = 0uL
158+
val claimableBalances = balanceDetails.lightningBalances
159+
.filterIsInstance<LightningBalance.ClaimableAwaitingConfirmations>()
160+
for (balance in claimableBalances) {
161+
val isOrphanCoopClose = balance.source == BalanceSource.COOP_CLOSE &&
162+
balance.channelId !in channelIds &&
163+
balance.channelId !in transferChannelIds
164+
165+
if (isOrphanCoopClose) {
166+
amount = amount.safe() + balance.amountSatoshis.safe()
167+
}
168+
}
169+
return amount
170+
}
171+
142172
private suspend fun getMaxSendAmount(balanceDetails: BalanceDetails): ULong {
143173
val spendableOnchainSats = balanceDetails.spendableOnchainBalanceSats
144174
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
@@ -368,8 +369,7 @@ class TransferRepoTest : BaseUnitTest() {
368369
}
369370

370371
@Test
371-
fun `syncTransferStates settles TO_SAVINGS transfer when balances is null`() = test {
372-
val settledAt = setupClockNowMock()
372+
fun `syncTransferStates does not settle TO_SAVINGS transfer when balances are unavailable`() = test {
373373
val transfer = TransferEntity(
374374
id = ID_TRANSFER,
375375
type = TransferType.TO_SAVINGS,
@@ -381,13 +381,12 @@ class TransferRepoTest : BaseUnitTest() {
381381

382382
whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer)))
383383
whenever(lightningRepo.getChannels()).thenReturn(emptyList())
384-
whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.failure(Exception("Error")))
385-
whenever(transferDao.markSettled(any(), any())).thenReturn(Unit)
384+
whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.failure(AppError("Balances unavailable")))
386385

387386
val result = sut.syncTransferStates()
388387

389388
assertTrue(result.isSuccess)
390-
verify(transferDao).markSettled(eq(ID_TRANSFER), eq(settledAt))
389+
verify(transferDao, never()).markSettled(any(), any())
391390
}
392391

393392
@Test
@@ -498,6 +497,27 @@ class TransferRepoTest : BaseUnitTest() {
498497
verify(transferDao, never()).markSettled(any(), any())
499498
}
500499

500+
@Test
501+
fun `syncTransferStates does not settle COOP_CLOSE when balances are unavailable`() = test {
502+
val transfer = TransferEntity(
503+
id = ID_TRANSFER,
504+
type = TransferType.COOP_CLOSE,
505+
amountSats = 75000L,
506+
channelId = ID_CHANNEL,
507+
isSettled = false,
508+
createdAt = 1000L,
509+
)
510+
511+
whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer)))
512+
whenever(lightningRepo.getChannels()).thenReturn(emptyList())
513+
whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.failure(AppError("Balances unavailable")))
514+
515+
val result = sut.syncTransferStates()
516+
517+
assertTrue(result.isSuccess)
518+
verify(transferDao, never()).markSettled(any(), any())
519+
}
520+
501521
@Test
502522
fun `syncTransferStates settles COOP_CLOSE when LDK balance is gone`() = test {
503523
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
@@ -312,6 +312,66 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() {
312312
)
313313
}
314314

315+
@Test
316+
fun `should subtract orphan coop close balance from lightning while keeping new channel balance`() = test {
317+
val closedChannelId = "closed-channel-id"
318+
val newChannelId = "new-channel-id"
319+
val closedChannelSats = 1_531_123uL
320+
val newChannelSats = 62_158uL
321+
val orphanClosingBalance = newClosingChannelBalance(closedChannelId, closedChannelSats)
322+
val newChannelBalance = newChannelBalance(newChannelId, newChannelSats)
323+
324+
val balance = newBalanceDetails().copy(
325+
totalOnchainBalanceSats = closedChannelSats,
326+
totalLightningBalanceSats = 1_593_281uL,
327+
lightningBalances = listOf(orphanClosingBalance, newChannelBalance),
328+
)
329+
whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balance))
330+
331+
val newChannel = mock<ChannelDetails> {
332+
on { channelId } doReturn newChannelId
333+
on { isChannelReady } doReturn true
334+
}
335+
336+
whenever(lightningRepo.getChannels()).thenReturn(listOf(newChannel))
337+
whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList()))
338+
339+
val result = sut()
340+
341+
assertTrue(result.isSuccess)
342+
val balanceState = result.getOrThrow()
343+
assertEquals(closedChannelSats, balanceState.totalOnchainSats)
344+
assertEquals(newChannelSats, balanceState.totalLightningSats)
345+
assertEquals(0uL, balanceState.balanceInTransferToSavings)
346+
assertEquals(0uL, balanceState.balanceInTransferToSpending)
347+
}
348+
349+
@Test
350+
fun `should not subtract orphan non coop close balance from lightning`() = test {
351+
val channelId = "force-closed-channel-id"
352+
val amountSats = 40_000uL
353+
val closingChannelBalance = newClosingChannelBalance(
354+
id = channelId,
355+
sats = amountSats,
356+
source = BalanceSource.COUNTERPARTY_FORCE_CLOSED,
357+
)
358+
359+
val balance = newBalanceDetails().copy(
360+
lightningBalances = listOf(closingChannelBalance),
361+
totalLightningBalanceSats = amountSats,
362+
)
363+
whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balance))
364+
365+
whenever(lightningRepo.getChannels()).thenReturn(emptyList())
366+
whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList()))
367+
368+
val result = sut()
369+
370+
assertTrue(result.isSuccess)
371+
val balanceState = result.getOrThrow()
372+
assertEquals(amountSats, balanceState.totalLightningSats)
373+
}
374+
315375
@Test
316376
fun `should calculate zero max send onchain when spendable balance is zero`() = test {
317377
val balance = newBalanceDetails().copy(totalOnchainBalanceSats = 50_000u)
@@ -466,12 +526,16 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() {
466526
inboundHtlcRoundedMsat = 0u,
467527
)
468528

469-
private fun newClosingChannelBalance(id: String, sats: ULong) = LightningBalance.ClaimableAwaitingConfirmations(
529+
private fun newClosingChannelBalance(
530+
id: String,
531+
sats: ULong,
532+
source: BalanceSource = BalanceSource.COOP_CLOSE,
533+
) = LightningBalance.ClaimableAwaitingConfirmations(
470534
channelId = id,
471535
counterpartyNodeId = "node-id",
472536
amountSatoshis = sats,
473537
confirmationHeight = 344u,
474-
source = BalanceSource.COOP_CLOSE,
538+
source = source,
475539
)
476540

477541
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)