From 440f2b1f62376ed7b6ed73c3b9fa40cb32c251c0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 25 Feb 2026 14:57:37 -0300 Subject: [PATCH 01/27] feat: add PendingSweepBalance extension functions --- .../java/to/bitkit/ext/PendingSweepBalance.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 app/src/main/java/to/bitkit/ext/PendingSweepBalance.kt diff --git a/app/src/main/java/to/bitkit/ext/PendingSweepBalance.kt b/app/src/main/java/to/bitkit/ext/PendingSweepBalance.kt new file mode 100644 index 000000000..21dc46236 --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/PendingSweepBalance.kt @@ -0,0 +1,19 @@ +package to.bitkit.ext + +import org.lightningdevkit.ldknode.PendingSweepBalance + +fun PendingSweepBalance.channelId(): String? { + return when (this) { + is PendingSweepBalance.PendingBroadcast -> this.channelId + is PendingSweepBalance.BroadcastAwaitingConfirmation -> this.channelId + is PendingSweepBalance.AwaitingThresholdConfirmations -> this.channelId + } +} + +fun PendingSweepBalance.latestSpendingTxid(): String? { + return when (this) { + is PendingSweepBalance.PendingBroadcast -> null + is PendingSweepBalance.BroadcastAwaitingConfirmation -> this.latestSpendingTxid + is PendingSweepBalance.AwaitingThresholdConfirmations -> this.latestSpendingTxid + } +} From 14db4c9861b2a60aa8a7e2d9301f9527a0372722 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 25 Feb 2026 15:05:07 -0300 Subject: [PATCH 02/27] fix: improve syncTransferStates for batched force-close sweeps --- .../to/bitkit/repositories/TransferRepo.kt | 54 ++++++++++++++++++- .../java/to/bitkit/services/CoreService.kt | 8 +++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index 6aa2e8708..6791aa6e7 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -8,8 +8,11 @@ import org.lightningdevkit.ldknode.ChannelDetails import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.TransferEntity import to.bitkit.di.BgDispatcher +import org.lightningdevkit.ldknode.PendingSweepBalance import to.bitkit.ext.channelId +import to.bitkit.ext.latestSpendingTxid import to.bitkit.models.TransferType +import to.bitkit.services.CoreService import to.bitkit.utils.Logger import java.util.UUID import javax.inject.Inject @@ -23,6 +26,7 @@ class TransferRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningRepo: LightningRepo, private val blocktankRepo: BlocktankRepo, + private val coreService: CoreService, private val transferDao: TransferDao, private val clock: Clock, ) { @@ -98,8 +102,12 @@ class TransferRepo @Inject constructor( } ?: false if (!hasBalance) { - markSettled(transfer.id) - Logger.debug("Channel $channelId balance swept, settled transfer: ${transfer.id}", context = TAG) + 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) + } } } }.onSuccess { @@ -109,6 +117,48 @@ class TransferRepo @Inject constructor( } } + private suspend fun settleForceClose( + transfer: TransferEntity, + channelId: String?, + pendingSweeps: List?, + ) { + if (channelId == null) return + + if (coreService.activity.hasOnchainActivityForChannel(channelId)) { + markSettled(transfer.id) + Logger.debug("Force close sweep detected, settled transfer: ${transfer.id}", context = TAG) + return + } + + // When LDK batches sweeps from multiple channels into one transaction, + // the on-chain activity may only be linked to one channel. Fall back to + // checking if there are no remaining pending sweep balances for this channel. + val pendingSweep = pendingSweeps?.find { it.channelId() == channelId } + + if (pendingSweep == null) { + markSettled(transfer.id) + Logger.debug( + "Force close sweep completed (no pending sweeps), settled transfer: ${transfer.id}", + context = TAG, + ) + return + } + + val sweepTxid = pendingSweep.latestSpendingTxid() + if (sweepTxid != null && coreService.activity.hasOnchainActivityForTxid(sweepTxid)) { + // The sweep tx was already synced as an on-chain activity (linked to another + // channel in the same batched sweep). Safe to settle this transfer. + markSettled(transfer.id) + Logger.debug( + "Force close batched sweep detected via txid $sweepTxid, settled transfer: ${transfer.id}", + context = TAG, + ) + return + } + + Logger.debug("Force close awaiting sweep detection for transfer: ${transfer.id}", context = TAG) + } + /** Resolve channelId: for LSP orders: via order->fundingTx match, for manual: directly. */ suspend fun resolveChannelIdForTransfer( transfer: TransferEntity, diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 4f82a9ece..e986bf65b 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -283,6 +283,14 @@ class ActivityService( getActivityByTxId(txId = txId) } + suspend fun hasOnchainActivityForChannel(channelId: String): Boolean { + val activities = get(filter = ActivityFilter.ONCHAIN, limit = 50u, sortDirection = SortDirection.DESC) + return activities.any { it is Activity.Onchain && it.v1.channelId == channelId } + } + + suspend fun hasOnchainActivityForTxid(txid: String): Boolean = + getOnchainActivityByTxId(txid) != null + @Suppress("LongParameterList") suspend fun get( filter: ActivityFilter? = null, From 28f19937b964bc00570b9af553fcf23a7b0c4297 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 25 Feb 2026 15:08:14 -0300 Subject: [PATCH 03/27] fix: add ConnectionClosed sheet variant and UI --- app/src/main/java/to/bitkit/ui/ContentView.kt | 5 + .../java/to/bitkit/ui/components/SheetHost.kt | 1 + .../bitkit/ui/sheets/ConnectionClosedSheet.kt | 93 +++++++++++++++++++ app/src/main/res/values/strings.xml | 2 + 4 files changed, 101 insertions(+) create mode 100644 app/src/main/java/to/bitkit/ui/sheets/ConnectionClosedSheet.kt diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 45ae8f1d5..bde072bf9 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -178,6 +178,7 @@ import to.bitkit.ui.sheets.LnurlAuthSheet import to.bitkit.ui.sheets.PinSheet import to.bitkit.ui.sheets.QuickPayIntroSheet import to.bitkit.ui.sheets.SendSheet +import to.bitkit.ui.sheets.ConnectionClosedSheet import to.bitkit.ui.sheets.SweepPromptSheet import to.bitkit.ui.sheets.UpdateSheet import to.bitkit.ui.theme.TRANSITION_SHEET_MS @@ -413,6 +414,10 @@ fun ContentView( onCancel = { appViewModel.hideSheet() }, ) + Sheet.ConnectionClosed -> ConnectionClosedSheet( + onDismiss = { appViewModel.hideSheet() }, + ) + is Sheet.Gift -> GiftSheet(sheet, appViewModel) is Sheet.TimedSheet -> { when (sheet.type) { diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index 599569b21..6d5fb4d8a 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -46,6 +46,7 @@ sealed interface Sheet { data object ForceTransfer : Sheet data class Gift(val code: String, val amount: ULong) : Sheet data object SweepPrompt : Sheet + data object ConnectionClosed : Sheet data class TimedSheet(val type: TimedSheetType) : Sheet } diff --git a/app/src/main/java/to/bitkit/ui/sheets/ConnectionClosedSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/ConnectionClosedSheet.kt new file mode 100644 index 000000000..f30c25f0c --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/ConnectionClosedSheet.kt @@ -0,0 +1,93 @@ +package to.bitkit.ui.sheets + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import to.bitkit.R +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors + +@Composable +fun ConnectionClosedSheet( + onDismiss: () -> Unit, +) { + Content(onDismiss = onDismiss) +} + +@Composable +private fun Content( + modifier: Modifier = Modifier, + onDismiss: () -> Unit = {}, +) { + Column( + modifier = modifier + .sheetHeight() + .gradientBackground() + .navigationBarsPadding() + .padding(horizontal = 16.dp) + .testTag("ConnectionClosedSheet") + ) { + SheetTopBar(titleText = stringResource(R.string.lightning__connection_closed__title)) + + BodyM( + text = stringResource(R.string.lightning__connection_closed__description), + color = Colors.White64, + modifier = Modifier.fillMaxWidth() + ) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Image( + painter = painterResource(R.drawable.switch_box), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(256.dp) + ) + } + + PrimaryButton( + text = stringResource(R.string.common__ok), + onClick = onDismiss, + modifier = Modifier + .fillMaxWidth() + .testTag("ConnectionClosedButton") + ) + + VerticalSpacer(16.dp) + } +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + Content() + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d080f226..141da712a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -111,6 +111,8 @@ Open connections Pending connections Connection + The funds on your spending balance have been transferred to your savings. + Connection Closed Lightning Connections Created on Bitkit could not add the Lightning peer. From 03c9cb54347bf119982f3c0a186304aa369dfc83 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 25 Feb 2026 15:12:59 -0300 Subject: [PATCH 04/27] fix: update handleChannelClosed in AppViewModel --- .../java/to/bitkit/viewmodels/AppViewModel.kt | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f0d371c8c..cd7370ecf 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -47,6 +47,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.lightningdevkit.ldknode.ChannelDataMigration +import org.lightningdevkit.ldknode.ClosureReason import org.lightningdevkit.ldknode.Event import org.lightningdevkit.ldknode.PaymentFailureReason import org.lightningdevkit.ldknode.PaymentId @@ -65,6 +66,8 @@ import to.bitkit.env.Defaults import to.bitkit.env.Env import to.bitkit.ext.WatchResult import to.bitkit.ext.amountOnClose +import to.bitkit.ext.amountSats +import to.bitkit.ext.channelId import to.bitkit.ext.getClipboardText import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.maxSendableSat @@ -84,6 +87,7 @@ import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.Suggestion import to.bitkit.models.Toast +import to.bitkit.models.TransferType import to.bitkit.models.TransactionSpeed import to.bitkit.models.safe import to.bitkit.models.toActivityFilter @@ -319,7 +323,7 @@ class AppViewModel @Inject constructor( runCatching { when (event) { is Event.BalanceChanged -> handleBalanceChanged() - is Event.ChannelClosed -> handleChannelClosed() + is Event.ChannelClosed -> handleChannelClosed(event) is Event.ChannelPending -> handleChannelPending() is Event.ChannelReady -> handleChannelReady(event) is Event.OnchainTransactionConfirmed -> handleOnchainTransactionConfirmed(event) @@ -357,11 +361,54 @@ class AppViewModel @Inject constructor( private suspend fun handleChannelPending() = transferRepo.syncTransferStates() - private suspend fun handleChannelClosed() { + private suspend fun handleChannelClosed(event: Event.ChannelClosed) { + val reason = event.reason + if (reason != null) { + val (isCounterpartyClose, isForceClose) = classifyClosureReason(reason) + if (isCounterpartyClose) { + createTransferForCounterpartyClose(event.channelId, isForceClose) + showSheet(Sheet.ConnectionClosed) + } + } transferRepo.syncTransferStates() walletRepo.syncBalances() } + private suspend fun createTransferForCounterpartyClose(channelId: String, isForceClose: Boolean) { + val transferType = if (isForceClose) TransferType.FORCE_CLOSE else TransferType.COOP_CLOSE + + val balances = lightningRepo.getBalancesAsync().getOrNull() + val lightningBalance = balances?.lightningBalances?.find { it.channelId() == channelId } + var channelBalance = lightningBalance?.amountSats() ?: 0uL + + if (channelBalance == 0uL) { + val closedChannels = runCatching { + coreService.activity.closedChannels(com.synonym.bitkitcore.SortDirection.DESC) + }.getOrNull() + channelBalance = closedChannels + ?.firstOrNull { it.channelId == channelId } + ?.channelValueSats ?: 0uL + } + + if (channelBalance > 0uL) { + transferRepo.createTransfer( + type = transferType, + amountSats = channelBalance.toLong(), + channelId = channelId, + ) + } + } + + private fun classifyClosureReason(reason: ClosureReason): Pair { + return when (reason) { + is ClosureReason.CounterpartyForceClosed -> true to true + is ClosureReason.CommitmentTxConfirmed -> true to true + is ClosureReason.CounterpartyInitiatedCooperativeClosure -> true to false + is ClosureReason.CounterpartyCoopClosedUnfundedChannel -> true to false + else -> false to false + } + } + private suspend fun handleSyncCompleted() { val isShowingLoading = migrationService.isShowingMigrationLoading.value val isRestoringRemote = migrationService.isRestoringFromRNRemoteBackup.value From 71aeef64967cf0a2f61f666a276933ac2312812a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 25 Feb 2026 15:19:46 -0300 Subject: [PATCH 05/27] test: add force-close sync tests --- .../bitkit/repositories/TransferRepoTest.kt | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index 59ac8dae1..1b5f75bf5 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -12,6 +12,7 @@ import org.lightningdevkit.ldknode.BalanceDetails import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.LightningBalance import org.lightningdevkit.ldknode.OutPoint +import org.lightningdevkit.ldknode.PendingSweepBalance import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq @@ -23,6 +24,8 @@ import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.TransferEntity import to.bitkit.ext.createChannelDetails import to.bitkit.models.TransferType +import to.bitkit.services.ActivityService +import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -38,6 +41,10 @@ class TransferRepoTest : BaseUnitTest() { private val transferDao = mock() private val lightningRepo = mock() private val blocktankRepo = mock() + private val activityService = mock() + private val coreService = mock { + on { activity } doReturn activityService + } private val clock = mock() companion object Fixtures { @@ -55,6 +62,7 @@ class TransferRepoTest : BaseUnitTest() { bgDispatcher = testDispatcher, lightningRepo = lightningRepo, blocktankRepo = blocktankRepo, + coreService = coreService, transferDao = transferDao, clock = clock, ) @@ -446,6 +454,152 @@ class TransferRepoTest : BaseUnitTest() { assertEquals(exception, result.exceptionOrNull()) } + // MARK: - syncTransferStates (force close sweep handling) + + @Test + fun `syncTransferStates settles FORCE_CLOSE when on-chain activity exists for channel`() = test { + val settledAt = setupClockNowMock() + val transfer = TransferEntity( + id = ID_TRANSFER, + type = TransferType.FORCE_CLOSE, + amountSats = 75000L, + channelId = ID_CHANNEL, + isSettled = false, + createdAt = 1000L, + ) + + val balances = BalanceDetails( + totalOnchainBalanceSats = 0u, + spendableOnchainBalanceSats = 0u, + totalAnchorChannelsReserveSats = 0u, + totalLightningBalanceSats = 0u, + lightningBalances = emptyList(), + pendingBalancesFromChannelClosures = emptyList(), + ) + + whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer))) + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) + whenever(activityService.hasOnchainActivityForChannel(ID_CHANNEL)).thenReturn(true) + whenever(transferDao.markSettled(any(), any())).thenReturn(Unit) + + val result = sut.syncTransferStates() + + assertTrue(result.isSuccess) + verify(transferDao).markSettled(eq(ID_TRANSFER), eq(settledAt)) + } + + @Test + fun `syncTransferStates settles FORCE_CLOSE when no pending sweeps remain`() = test { + val settledAt = setupClockNowMock() + val transfer = TransferEntity( + id = ID_TRANSFER, + type = TransferType.FORCE_CLOSE, + amountSats = 75000L, + channelId = ID_CHANNEL, + isSettled = false, + createdAt = 1000L, + ) + + val balances = BalanceDetails( + totalOnchainBalanceSats = 0u, + spendableOnchainBalanceSats = 0u, + totalAnchorChannelsReserveSats = 0u, + totalLightningBalanceSats = 0u, + lightningBalances = emptyList(), + pendingBalancesFromChannelClosures = emptyList(), + ) + + whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer))) + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) + whenever(activityService.hasOnchainActivityForChannel(ID_CHANNEL)).thenReturn(false) + whenever(transferDao.markSettled(any(), any())).thenReturn(Unit) + + val result = sut.syncTransferStates() + + assertTrue(result.isSuccess) + verify(transferDao).markSettled(eq(ID_TRANSFER), eq(settledAt)) + } + + @Test + fun `syncTransferStates does not settle FORCE_CLOSE when pending sweep still exists`() = test { + val transfer = TransferEntity( + id = ID_TRANSFER, + type = TransferType.FORCE_CLOSE, + amountSats = 75000L, + channelId = ID_CHANNEL, + isSettled = false, + createdAt = 1000L, + ) + + val pendingSweep = PendingSweepBalance.PendingBroadcast( + channelId = ID_CHANNEL, + amountSatoshis = 75000u, + ) + + val balances = BalanceDetails( + totalOnchainBalanceSats = 0u, + spendableOnchainBalanceSats = 0u, + totalAnchorChannelsReserveSats = 0u, + totalLightningBalanceSats = 0u, + lightningBalances = emptyList(), + pendingBalancesFromChannelClosures = listOf(pendingSweep), + ) + + whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer))) + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) + whenever(activityService.hasOnchainActivityForChannel(ID_CHANNEL)).thenReturn(false) + + val result = sut.syncTransferStates() + + assertTrue(result.isSuccess) + verify(transferDao, never()).markSettled(any(), any()) + } + + @Test + fun `syncTransferStates settles FORCE_CLOSE via batched sweep txid`() = test { + val settledAt = setupClockNowMock() + val sweepTxid = "batched-sweep-txid" + val transfer = TransferEntity( + id = ID_TRANSFER, + type = TransferType.FORCE_CLOSE, + amountSats = 75000L, + channelId = ID_CHANNEL, + isSettled = false, + createdAt = 1000L, + ) + + val pendingSweep = PendingSweepBalance.BroadcastAwaitingConfirmation( + channelId = ID_CHANNEL, + latestBroadcastHeight = 800000u, + latestSpendingTxid = sweepTxid, + amountSatoshis = 75000u, + ) + + val balances = BalanceDetails( + totalOnchainBalanceSats = 0u, + spendableOnchainBalanceSats = 0u, + totalAnchorChannelsReserveSats = 0u, + totalLightningBalanceSats = 0u, + lightningBalances = emptyList(), + pendingBalancesFromChannelClosures = listOf(pendingSweep), + ) + + whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer))) + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) + whenever(activityService.hasOnchainActivityForChannel(ID_CHANNEL)).thenReturn(false) + whenever(activityService.hasOnchainActivityForTxid(sweepTxid)).thenReturn(true) + whenever(transferDao.markSettled(any(), any())).thenReturn(Unit) + + val result = sut.syncTransferStates() + + assertTrue(result.isSuccess) + verify(transferDao).markSettled(eq(ID_TRANSFER), eq(settledAt)) + } + // MARK: - resolveChannelIdForTransfer @Test @@ -629,6 +783,7 @@ class TransferRepoTest : BaseUnitTest() { bgDispatcher = testDispatcher, lightningRepo = lightningRepo, blocktankRepo = blocktankRepo, + coreService = coreService, transferDao = transferDao, clock = clock, ) From a1798210655ca1970a2fc83ba71fea884007d761 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 25 Feb 2026 15:23:47 -0300 Subject: [PATCH 06/27] chore: lint --- app/src/main/java/to/bitkit/repositories/TransferRepo.kt | 7 +++++-- app/src/main/java/to/bitkit/ui/ContentView.kt | 2 +- .../main/java/to/bitkit/ui/sheets/ConnectionClosedSheet.kt | 1 - app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 2 +- .../test/java/to/bitkit/repositories/TransferRepoTest.kt | 1 + 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index 6791aa6e7..d9ca372eb 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -5,10 +5,10 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.ChannelDetails +import org.lightningdevkit.ldknode.PendingSweepBalance import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.TransferEntity import to.bitkit.di.BgDispatcher -import org.lightningdevkit.ldknode.PendingSweepBalance import to.bitkit.ext.channelId import to.bitkit.ext.latestSpendingTxid import to.bitkit.models.TransferType @@ -106,7 +106,10 @@ class TransferRepo @Inject constructor( settleForceClose(transfer, channelId, balances?.pendingBalancesFromChannelClosures) } else { markSettled(transfer.id) - Logger.debug("Channel $channelId balance swept, settled transfer: ${transfer.id}", context = TAG) + Logger.debug( + "Channel $channelId balance swept, settled transfer: ${transfer.id}", + context = TAG + ) } } } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index bde072bf9..4663a31fc 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -171,6 +171,7 @@ import to.bitkit.ui.settings.transactionSpeed.TransactionSpeedSettingsScreen import to.bitkit.ui.sheets.BackgroundPaymentsIntroSheet import to.bitkit.ui.sheets.BackupRoute import to.bitkit.ui.sheets.BackupSheet +import to.bitkit.ui.sheets.ConnectionClosedSheet import to.bitkit.ui.sheets.ForceTransferSheet import to.bitkit.ui.sheets.GiftSheet import to.bitkit.ui.sheets.HighBalanceWarningSheet @@ -178,7 +179,6 @@ import to.bitkit.ui.sheets.LnurlAuthSheet import to.bitkit.ui.sheets.PinSheet import to.bitkit.ui.sheets.QuickPayIntroSheet import to.bitkit.ui.sheets.SendSheet -import to.bitkit.ui.sheets.ConnectionClosedSheet import to.bitkit.ui.sheets.SweepPromptSheet import to.bitkit.ui.sheets.UpdateSheet import to.bitkit.ui.theme.TRANSITION_SHEET_MS diff --git a/app/src/main/java/to/bitkit/ui/sheets/ConnectionClosedSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/ConnectionClosedSheet.kt index f30c25f0c..eaea605c3 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/ConnectionClosedSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/ConnectionClosedSheet.kt @@ -19,7 +19,6 @@ import androidx.compose.ui.unit.dp import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BottomSheetPreview -import to.bitkit.ui.components.Display import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index cd7370ecf..869988ab2 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -87,8 +87,8 @@ import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.Suggestion import to.bitkit.models.Toast -import to.bitkit.models.TransferType import to.bitkit.models.TransactionSpeed +import to.bitkit.models.TransferType import to.bitkit.models.safe import to.bitkit.models.toActivityFilter import to.bitkit.models.toLdkNetwork diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index 1b5f75bf5..4b8b58b5d 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -34,6 +34,7 @@ import kotlin.test.assertTrue import kotlin.time.Clock import kotlin.time.ExperimentalTime +@Suppress("LargeClass") @OptIn(ExperimentalTime::class) class TransferRepoTest : BaseUnitTest() { private lateinit var sut: TransferRepo From 33a77ec84f2d798c3c22f52ffe893d949c9da885 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 26 Feb 2026 06:41:05 -0300 Subject: [PATCH 07/27] chore: lint --- app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 869988ab2..6cc955cef 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -22,6 +22,7 @@ import com.synonym.bitkitcore.LnurlWithdrawData import com.synonym.bitkitcore.OnChainInvoice import com.synonym.bitkitcore.PaymentType import com.synonym.bitkitcore.Scanner +import com.synonym.bitkitcore.SortDirection import com.synonym.bitkitcore.validateBitcoinAddress import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -383,7 +384,7 @@ class AppViewModel @Inject constructor( if (channelBalance == 0uL) { val closedChannels = runCatching { - coreService.activity.closedChannels(com.synonym.bitkitcore.SortDirection.DESC) + coreService.activity.closedChannels(SortDirection.DESC) }.getOrNull() channelBalance = closedChannels ?.firstOrNull { it.channelId == channelId } @@ -2277,7 +2278,6 @@ class AppViewModel @Inject constructor( } // TODO Temporary fix while these schemes can't be decoded https://github.com/synonymdev/bitkit-core/issues/70 - @Suppress("SpellCheckingInspection") private fun String.removeLightningSchemes(): String { return this .replace(Regex("^lightning:", RegexOption.IGNORE_CASE), "") From 356a1d7307fc474d022fc080d774ab0121e3752b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 26 Feb 2026 08:17:26 -0300 Subject: [PATCH 08/27] fix: mark closing transaction as transfer activity --- .../to/bitkit/repositories/TransferRepo.kt | 27 ++++++++++ .../bitkit/repositories/TransferRepoTest.kt | 50 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index d9ca372eb..c8dc92847 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -1,5 +1,8 @@ package to.bitkit.repositories +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.ActivityFilter +import com.synonym.bitkitcore.SortDirection import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -128,6 +131,7 @@ class TransferRepo @Inject constructor( if (channelId == null) return if (coreService.activity.hasOnchainActivityForChannel(channelId)) { + markActivityAsTransferByChannel(channelId) markSettled(transfer.id) Logger.debug("Force close sweep detected, settled transfer: ${transfer.id}", context = TAG) return @@ -151,6 +155,7 @@ class TransferRepo @Inject constructor( if (sweepTxid != null && coreService.activity.hasOnchainActivityForTxid(sweepTxid)) { // The sweep tx was already synced as an on-chain activity (linked to another // channel in the same batched sweep). Safe to settle this transfer. + markActivityAsTransfer(sweepTxid, channelId) markSettled(transfer.id) Logger.debug( "Force close batched sweep detected via txid $sweepTxid, settled transfer: ${transfer.id}", @@ -162,6 +167,28 @@ class TransferRepo @Inject constructor( Logger.debug("Force close awaiting sweep detection for transfer: ${transfer.id}", context = TAG) } + private suspend fun markActivityAsTransfer(txid: String, channelId: String) { + val activity = coreService.activity.getOnchainActivityByTxId(txid) ?: return + if (activity.isTransfer) return + val updated = activity.copy(isTransfer = true, channelId = channelId) + coreService.activity.update(activity.id, Activity.Onchain(updated)) + Logger.debug("Marked activity ${activity.id} as transfer for channel $channelId", context = TAG) + } + + private suspend fun markActivityAsTransferByChannel(channelId: String) { + val activities = coreService.activity.get( + filter = ActivityFilter.ONCHAIN, + limit = 50u, + sortDirection = SortDirection.DESC, + ) + val activity = activities.firstOrNull { it is Activity.Onchain && it.v1.channelId == channelId } + as? Activity.Onchain ?: return + if (activity.v1.isTransfer) return + val updated = activity.v1.copy(isTransfer = true, channelId = channelId) + coreService.activity.update(activity.v1.id, Activity.Onchain(updated)) + Logger.debug("Marked activity ${activity.v1.id} as transfer for channel $channelId", context = TAG) + } + /** Resolve channelId: for LSP orders: via order->fundingTx match, for manual: directly. */ suspend fun resolveChannelIdForTransfer( transfer: TransferEntity, diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index 4b8b58b5d..b28c13872 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -1,9 +1,12 @@ package to.bitkit.repositories import app.cash.turbine.test +import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.FundingTx import com.synonym.bitkitcore.IBtChannel import com.synonym.bitkitcore.IBtOrder +import com.synonym.bitkitcore.OnchainActivity +import com.synonym.bitkitcore.PaymentType import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import org.junit.Before @@ -14,6 +17,7 @@ import org.lightningdevkit.ldknode.LightningBalance import org.lightningdevkit.ldknode.OutPoint import org.lightningdevkit.ldknode.PendingSweepBalance import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -22,6 +26,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.TransferEntity +import to.bitkit.ext.create import to.bitkit.ext.createChannelDetails import to.bitkit.models.TransferType import to.bitkit.services.ActivityService @@ -469,6 +474,18 @@ class TransferRepoTest : BaseUnitTest() { createdAt = 1000L, ) + val sweepActivity = OnchainActivity.create( + id = "sweep-activity-id", + txType = PaymentType.RECEIVED, + txId = "sweep-txid", + value = 75000u, + fee = 0u, + address = "bc1test", + timestamp = 1000u, + isTransfer = false, + channelId = ID_CHANNEL, + ) + val balances = BalanceDetails( totalOnchainBalanceSats = 0u, spendableOnchainBalanceSats = 0u, @@ -482,12 +499,29 @@ class TransferRepoTest : BaseUnitTest() { whenever(lightningRepo.getChannels()).thenReturn(emptyList()) whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) whenever(activityService.hasOnchainActivityForChannel(ID_CHANNEL)).thenReturn(true) + whenever( + activityService.get( + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + ) + .thenReturn(listOf(Activity.Onchain(sweepActivity))) whenever(transferDao.markSettled(any(), any())).thenReturn(Unit) val result = sut.syncTransferStates() assertTrue(result.isSuccess) verify(transferDao).markSettled(eq(ID_TRANSFER), eq(settledAt)) + verify(activityService).update( + eq(sweepActivity.id), + eq(Activity.Onchain(sweepActivity.copy(isTransfer = true, channelId = ID_CHANNEL))), + ) } @Test @@ -572,6 +606,17 @@ class TransferRepoTest : BaseUnitTest() { createdAt = 1000L, ) + val sweepActivity = OnchainActivity.create( + id = "sweep-activity-id", + txType = PaymentType.RECEIVED, + txId = sweepTxid, + value = 75000u, + fee = 0u, + address = "bc1test", + timestamp = 1000u, + isTransfer = false, + ) + val pendingSweep = PendingSweepBalance.BroadcastAwaitingConfirmation( channelId = ID_CHANNEL, latestBroadcastHeight = 800000u, @@ -593,12 +638,17 @@ class TransferRepoTest : BaseUnitTest() { whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) whenever(activityService.hasOnchainActivityForChannel(ID_CHANNEL)).thenReturn(false) whenever(activityService.hasOnchainActivityForTxid(sweepTxid)).thenReturn(true) + whenever(activityService.getOnchainActivityByTxId(sweepTxid)).thenReturn(sweepActivity) whenever(transferDao.markSettled(any(), any())).thenReturn(Unit) val result = sut.syncTransferStates() assertTrue(result.isSuccess) verify(transferDao).markSettled(eq(ID_TRANSFER), eq(settledAt)) + verify(activityService).update( + eq(sweepActivity.id), + eq(Activity.Onchain(sweepActivity.copy(isTransfer = true, channelId = ID_CHANNEL))), + ) } // MARK: - resolveChannelIdForTransfer From 7e284efbcf8bc06ef4fd50b5eca80224e7a394c6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 26 Feb 2026 15:32:11 -0300 Subject: [PATCH 09/27] fix: sheet size medium --- .../main/java/to/bitkit/ui/sheets/ConnectionClosedSheet.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/sheets/ConnectionClosedSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/ConnectionClosedSheet.kt index eaea605c3..c9a1a3e79 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/ConnectionClosedSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/ConnectionClosedSheet.kt @@ -20,6 +20,7 @@ import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SheetSize import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.sheetHeight @@ -41,7 +42,9 @@ private fun Content( ) { Column( modifier = modifier - .sheetHeight() + .sheetHeight( + size = SheetSize.MEDIUM + ) .gradientBackground() .navigationBarsPadding() .padding(horizontal = 16.dp) From 9c4f80db98a0f523526b46debd2e7c5f89150e23 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 27 Feb 2026 07:47:14 -0300 Subject: [PATCH 10/27] fix: settle COOP_CLOSE immediately --- app/src/main/java/to/bitkit/repositories/TransferRepo.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index c8dc92847..26b7d4dc9 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -99,6 +99,12 @@ class TransferRepo @Inject constructor( val toSavings = activeTransfers.filter { it.type.isToSavings() } for (transfer in toSavings) { + if (transfer.type == TransferType.COOP_CLOSE) { + markSettled(transfer.id) + Logger.debug("Coop close settled immediately: ${transfer.id}", context = TAG) + continue + } + val channelId = resolveChannelIdForTransfer(transfer, channels) val hasBalance = balances?.lightningBalances?.any { it.channelId() == channelId From 298bdf56300f6a33dd49ea36361ea839b12d64c9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 27 Feb 2026 07:49:22 -0300 Subject: [PATCH 11/27] fix: balance calculation includes COOP_CLOSE whole they are unsettled (6 blocks) --- .../usecases/DeriveBalanceStateUseCase.kt | 3 +- .../usecases/DeriveBalanceStateUseCaseTest.kt | 40 ++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index fd2cd74dd..bffc53fcf 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -9,6 +9,7 @@ import to.bitkit.ext.amountSats import to.bitkit.ext.channelId import to.bitkit.ext.totalNextOutboundHtlcLimitSats import to.bitkit.models.BalanceState +import to.bitkit.models.TransferType import to.bitkit.models.safe import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo @@ -86,7 +87,7 @@ class DeriveBalanceStateUseCase @Inject constructor( balanceDetails: BalanceDetails, ): ULong { var toSavingsAmount = 0uL - val toSavings = transfers.filter { it.type.isToSavings() } + val toSavings = transfers.filter { it.type.isToSavings() && it.type != TransferType.COOP_CLOSE } for (transfer in toSavings) { val channelId = transferRepo.resolveChannelIdForTransfer(transfer, channels) diff --git a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt index b9a7e48c3..d7a438134 100644 --- a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt @@ -164,7 +164,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { val transfers = listOf( newTransferEntity( - type = TransferType.COOP_CLOSE, + type = TransferType.FORCE_CLOSE, amountSats = amountSats.toLong(), channelId = channelId, lspOrderId = null @@ -192,6 +192,42 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { ) } + @Test + fun `should not count coop close channel balance for transfer to savings`() = test { + val channelId = "closing-channel-id" + val amountSats = 40_000uL + val closingChannelBalance = newClosingChannelBalance(channelId, amountSats) + + val balance = newBalanceDetails().copy( + lightningBalances = listOf(closingChannelBalance), + totalLightningBalanceSats = amountSats, + ) + wheneverBlocking { lightningRepo.getBalancesAsync() }.thenReturn(Result.success(balance)) + + val transfers = listOf( + newTransferEntity( + type = TransferType.COOP_CLOSE, + amountSats = amountSats.toLong(), + channelId = channelId, + lspOrderId = null + ) + ) + + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(transferRepo.activeTransfers).thenReturn(flowOf(transfers)) + + val result = sut() + + assertTrue(result.isSuccess) + val balanceState = result.getOrThrow() + assertEquals(0uL, balanceState.balanceInTransferToSavings) + assertEquals( + amountSats, + balanceState.totalLightningSats, + "Lightning balance not reduced - coop close funds are immediately spendable" + ) + } + @Test fun `should calculate zero max send onchain when spendable balance is zero`() = test { val balance = newBalanceDetails().copy(totalOnchainBalanceSats = 50_000u) @@ -292,7 +328,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { lspOrderId = null ), newTransferEntity( - type = TransferType.COOP_CLOSE, + type = TransferType.FORCE_CLOSE, amountSats = toSavings.toLong(), channelId = savingsChannelId, lspOrderId = null From d3af0c3aa7a240f9957971c38f21c58b7203095c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 27 Feb 2026 07:50:47 -0300 Subject: [PATCH 12/27] test: update coop close tests to settle immediately --- .../bitkit/repositories/TransferRepoTest.kt | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index b28c13872..f30d2d280 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -460,6 +460,46 @@ class TransferRepoTest : BaseUnitTest() { assertEquals(exception, result.exceptionOrNull()) } + @Test + fun `syncTransferStates settles COOP_CLOSE immediately without waiting for balance sweep`() = test { + val settledAt = setupClockNowMock() + val transfer = TransferEntity( + id = ID_TRANSFER, + type = TransferType.COOP_CLOSE, + amountSats = 75000L, + channelId = ID_CHANNEL, + isSettled = false, + createdAt = 1000L, + ) + + val lightningBalance = LightningBalance.ClaimableAwaitingConfirmations( + channelId = ID_CHANNEL, + counterpartyNodeId = "node123", + amountSatoshis = 75000u, + confirmationHeight = 344u, + source = org.lightningdevkit.ldknode.BalanceSource.COOP_CLOSE, + ) + + val balances = BalanceDetails( + totalOnchainBalanceSats = 0u, + spendableOnchainBalanceSats = 0u, + totalAnchorChannelsReserveSats = 0u, + totalLightningBalanceSats = 75000u, + lightningBalances = listOf(lightningBalance), + pendingBalancesFromChannelClosures = emptyList(), + ) + + whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer))) + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) + whenever(transferDao.markSettled(any(), any())).thenReturn(Unit) + + val result = sut.syncTransferStates() + + assertTrue(result.isSuccess) + verify(transferDao).markSettled(eq(ID_TRANSFER), eq(settledAt)) + } + // MARK: - syncTransferStates (force close sweep handling) @Test From 793e0b97d46b056fe8739a2646aa605802c76b2e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 27 Feb 2026 08:15:54 -0300 Subject: [PATCH 13/27] feat: add claimableAtHeight to TransferEntity --- app/src/main/java/to/bitkit/data/AppDb.kt | 10 +++++++++- .../java/to/bitkit/data/entities/TransferEntity.kt | 1 + app/src/main/java/to/bitkit/ext/LightningBalance.kt | 7 +++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/data/AppDb.kt b/app/src/main/java/to/bitkit/data/AppDb.kt index d67f22094..81038037a 100644 --- a/app/src/main/java/to/bitkit/data/AppDb.kt +++ b/app/src/main/java/to/bitkit/data/AppDb.kt @@ -9,6 +9,7 @@ import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.room.Upsert +import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import androidx.work.CoroutineWorker import androidx.work.OneTimeWorkRequestBuilder @@ -30,7 +31,7 @@ import to.bitkit.env.Env ConfigEntity::class, TransferEntity::class, ], - version = 5, + version = 6, ) @TypeConverters(StringListConverter::class) abstract class AppDb : RoomDatabase() { @@ -38,6 +39,12 @@ abstract class AppDb : RoomDatabase() { abstract fun transferDao(): TransferDao companion object { + private val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE transfers ADD COLUMN claimableAtHeight INTEGER DEFAULT NULL") + } + } + private const val DB_NAME = "${BuildConfig.APPLICATION_ID}.sqlite" @Volatile @@ -65,6 +72,7 @@ abstract class AppDb : RoomDatabase() { } } }) + .addMigrations(MIGRATION_5_6) .apply { if (Env.isDebug) fallbackToDestructiveMigration(dropAllTables = true) } diff --git a/app/src/main/java/to/bitkit/data/entities/TransferEntity.kt b/app/src/main/java/to/bitkit/data/entities/TransferEntity.kt index 12ae0deac..4dd48ef40 100644 --- a/app/src/main/java/to/bitkit/data/entities/TransferEntity.kt +++ b/app/src/main/java/to/bitkit/data/entities/TransferEntity.kt @@ -17,4 +17,5 @@ data class TransferEntity( val isSettled: Boolean = false, val createdAt: Long, val settledAt: Long? = null, + val claimableAtHeight: UInt? = null, ) diff --git a/app/src/main/java/to/bitkit/ext/LightningBalance.kt b/app/src/main/java/to/bitkit/ext/LightningBalance.kt index 5a910fe0e..542f3248e 100644 --- a/app/src/main/java/to/bitkit/ext/LightningBalance.kt +++ b/app/src/main/java/to/bitkit/ext/LightningBalance.kt @@ -24,6 +24,13 @@ fun LightningBalance.channelId(): String { } } +fun LightningBalance.claimableAtHeight(): UInt? = when (this) { + is LightningBalance.ClaimableAwaitingConfirmations -> this.confirmationHeight + is LightningBalance.ContentiousClaimable -> this.timeoutHeight + is LightningBalance.MaybeTimeoutClaimableHtlc -> this.claimableHeight + else -> null +} + fun LightningBalance.balanceUiText(): String { return when (this) { is LightningBalance.ClaimableOnChannelClose -> "Claimable on Channel Close" From f4849a448e24bb58a69bf1ab44f4fab45fff3425 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 27 Feb 2026 08:20:04 -0300 Subject: [PATCH 14/27] feat: pass claimableAtHeight when creating force close transfer --- app/src/main/java/to/bitkit/repositories/TransferRepo.kt | 2 ++ app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index 26b7d4dc9..8357393a4 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -41,6 +41,7 @@ class TransferRepo @Inject constructor( channelId: String? = null, fundingTxId: String? = null, lspOrderId: String? = null, + claimableAtHeight: UInt? = null, ): Result = withContext(bgDispatcher) { runCatching { val id = UUID.randomUUID().toString() @@ -54,6 +55,7 @@ class TransferRepo @Inject constructor( lspOrderId = lspOrderId, isSettled = false, createdAt = clock.now().epochSeconds, + claimableAtHeight = claimableAtHeight, ) ) Logger.info("Created transfer: id=$id type=$type channelId=$channelId", context = TAG) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 6cc955cef..bb8dade8f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -69,6 +69,7 @@ import to.bitkit.ext.WatchResult import to.bitkit.ext.amountOnClose import to.bitkit.ext.amountSats import to.bitkit.ext.channelId +import to.bitkit.ext.claimableAtHeight import to.bitkit.ext.getClipboardText import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.ext.maxSendableSat @@ -396,6 +397,7 @@ class AppViewModel @Inject constructor( type = transferType, amountSats = channelBalance.toLong(), channelId = channelId, + claimableAtHeight = lightningBalance?.claimableAtHeight(), ) } } From 877724474db6a2bce46f72d51aed8e29d326486d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 27 Feb 2026 08:20:47 -0300 Subject: [PATCH 15/27] chore: create helpers to display the duration --- .../java/to/bitkit/utils/BlockTimeHelpers.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 app/src/main/java/to/bitkit/utils/BlockTimeHelpers.kt diff --git a/app/src/main/java/to/bitkit/utils/BlockTimeHelpers.kt b/app/src/main/java/to/bitkit/utils/BlockTimeHelpers.kt new file mode 100644 index 000000000..00f5cdbc7 --- /dev/null +++ b/app/src/main/java/to/bitkit/utils/BlockTimeHelpers.kt @@ -0,0 +1,16 @@ +package to.bitkit.utils + +import kotlin.math.roundToInt + +object BlockTimeHelpers { + private const val BLOCK_TIME_MINUTES = 10 + + fun getDurationForBlocks(blocks: Int): String = when { + blocks > 143 -> "${(blocks * BLOCK_TIME_MINUTES / 60.0 / 24.0).roundToInt()}d" + blocks > 6 -> "${(blocks * BLOCK_TIME_MINUTES / 60.0).roundToInt()}h" + else -> "${blocks * BLOCK_TIME_MINUTES}m" + } + + fun blocksRemaining(targetHeight: UInt, currentHeight: UInt): Int = + maxOf(0, (targetHeight.toInt() - currentHeight.toInt())) +} \ No newline at end of file From 0aa2049f8a0dbfbf6d0094cc781fa8693352ec32 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 27 Feb 2026 08:24:54 -0300 Subject: [PATCH 16/27] feat: update banner for dynamic title --- .../to/bitkit/models/ActivityBannerType.kt | 5 +++ .../java/to/bitkit/models/BalanceState.kt | 2 + .../bitkit/ui/screens/wallets/HomeScreen.kt | 8 ++-- .../bitkit/ui/screens/wallets/HomeUiState.kt | 4 +- .../ui/screens/wallets/HomeViewModel.kt | 39 +++++++++++++------ .../usecases/DeriveBalanceStateUseCase.kt | 14 +++++++ app/src/main/res/values/strings.xml | 1 + 7 files changed, 55 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/ActivityBannerType.kt b/app/src/main/java/to/bitkit/models/ActivityBannerType.kt index cd366c294..7a6cf8a6c 100644 --- a/app/src/main/java/to/bitkit/models/ActivityBannerType.kt +++ b/app/src/main/java/to/bitkit/models/ActivityBannerType.kt @@ -22,3 +22,8 @@ enum class ActivityBannerType( title = R.string.lightning__transfer_in_progress ) } + +data class BannerItem( + val type: ActivityBannerType, + val title: String, +) diff --git a/app/src/main/java/to/bitkit/models/BalanceState.kt b/app/src/main/java/to/bitkit/models/BalanceState.kt index 07cfc7a23..60635bf9f 100644 --- a/app/src/main/java/to/bitkit/models/BalanceState.kt +++ b/app/src/main/java/to/bitkit/models/BalanceState.kt @@ -1,6 +1,7 @@ package to.bitkit.models import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient @Serializable data class BalanceState( @@ -10,6 +11,7 @@ data class BalanceState( val maxSendOnchainSats: ULong = 0uL, val balanceInTransferToSavings: ULong = 0uL, val balanceInTransferToSpending: ULong = 0uL, + @Transient val forceCloseRemainingDuration: String? = null, ) { val totalSats get() = totalOnchainSats + totalLightningSats } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 5367f742f..1543b821d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -469,11 +469,11 @@ private fun Content( ) { homeUiState.banners.forEach { banner -> ActivityBanner( - gradientColor = banner.color, - title = stringResource(banner.title), - icon = banner.icon, + gradientColor = banner.type.color, + title = banner.title, + icon = banner.type.icon, onClick = { - when (banner) { + when (banner.type) { ActivityBannerType.SPENDING -> rootNavController.navigate(Routes.SettingUp) ActivityBannerType.SAVINGS -> Unit } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt index 7b086e64e..bebb01e6e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt @@ -2,7 +2,7 @@ package to.bitkit.ui.screens.wallets import androidx.compose.runtime.Stable import to.bitkit.data.dto.price.PriceDTO -import to.bitkit.models.ActivityBannerType +import to.bitkit.models.BannerItem import to.bitkit.models.Suggestion import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition @@ -18,7 +18,7 @@ import to.bitkit.ui.screens.widgets.blocks.WeatherModel @Stable data class HomeUiState( val suggestions: List = listOf(), - val banners: List = listOf(), + val banners: List = listOf(), val showWidgets: Boolean = false, val showWidgetTitles: Boolean = false, val widgetsWithPosition: List = emptyList(), diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt index 5410ad6e3..c3d01fe70 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt @@ -12,7 +12,11 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.data.SettingsStore +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import to.bitkit.R import to.bitkit.models.ActivityBannerType +import to.bitkit.models.BannerItem import to.bitkit.models.Suggestion import to.bitkit.models.TransferType import to.bitkit.models.WidgetType @@ -29,6 +33,7 @@ import kotlin.time.Duration.Companion.seconds @HiltViewModel class HomeViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val walletRepo: WalletRepo, private val widgetsRepo: WidgetsRepo, private val settingsStore: SettingsStore, @@ -215,18 +220,28 @@ class HomeViewModel @Inject constructor( } private suspend fun createBannersFlow() { - transferRepo.activeTransfers - .collect { transfers -> - val banners = listOfNotNull( - ActivityBannerType.SPENDING.takeIf { - transfers.any { it.type.isToSpending() } - }, - ActivityBannerType.SAVINGS.takeIf { - transfers.any { it.type.isToSavings() } - }, - ) - _uiState.update { it.copy(banners = banners) } - } + combine( + transferRepo.activeTransfers, + walletRepo.balanceState, + ) { transfers, balanceState -> + val defaultTitle = context.getString(R.string.lightning__transfer_in_progress) + val savingsTitle = balanceState.forceCloseRemainingDuration?.let { + context.getString(R.string.lightning__transfer_ready_in, it) + } ?: defaultTitle + + listOfNotNull( + BannerItem( + type = ActivityBannerType.SPENDING, + title = defaultTitle, + ).takeIf { transfers.any { it.type.isToSpending() } }, + BannerItem( + type = ActivityBannerType.SAVINGS, + title = savingsTitle, + ).takeIf { transfers.any { it.type.isToSavings() } }, + ) + }.collect { banners -> + _uiState.update { it.copy(banners = banners) } + } } private fun createSuggestionsFlow() = combine( diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index bffc53fcf..5e395330a 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -8,6 +8,7 @@ import to.bitkit.data.entities.TransferEntity import to.bitkit.ext.amountSats import to.bitkit.ext.channelId import to.bitkit.ext.totalNextOutboundHtlcLimitSats +import to.bitkit.utils.BlockTimeHelpers import to.bitkit.models.BalanceState import to.bitkit.models.TransferType import to.bitkit.models.safe @@ -39,6 +40,8 @@ class DeriveBalanceStateUseCase @Inject constructor( val afterPendingChannels = balanceDetails.totalLightningBalanceSats.safe() - pendingChannelsSats.safe() val totalLightningSats = afterPendingChannels.safe() - toSavingsAmount.safe() + val forceCloseRemainingDuration = getForceCloseRemainingDuration(activeTransfers) + val balanceState = BalanceState( totalOnchainSats = totalOnchainSats, totalLightningSats = totalLightningSats, @@ -46,6 +49,7 @@ class DeriveBalanceStateUseCase @Inject constructor( maxSendOnchainSats = getMaxSendAmount(balanceDetails), balanceInTransferToSavings = toSavingsAmount, balanceInTransferToSpending = toSpendingAmount, + forceCloseRemainingDuration = forceCloseRemainingDuration, ) val height = lightningRepo.lightningState.value.block()?.height @@ -116,6 +120,16 @@ class DeriveBalanceStateUseCase @Inject constructor( return spendableOnchainSats.safe() - fee.safe() } + private fun getForceCloseRemainingDuration(transfers: List): String? { + val forceClose = transfers.firstOrNull { it.type == TransferType.FORCE_CLOSE } + ?: return null + val targetHeight = forceClose.claimableAtHeight ?: return null + val currentHeight = lightningRepo.lightningState.value.block()?.height ?: return null + val remaining = BlockTimeHelpers.blocksRemaining(targetHeight, currentHeight) + if (remaining <= 0) return null + return BlockTimeHelpers.getDurationForBlocks(remaining) + } + companion object { const val TAG = "DeriveBalanceStateUseCase" const val FALLBACK_FEE_PERCENT = 0.1 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 141da712a..5f1e7e035 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -246,6 +246,7 @@ Transfer Funds Swipe To Transfer TRANSFER IN PROGRESS + TRANSFER READY IN %s Get Started Fund your spending balance to enjoy instant and cheap transactions with friends, family, and merchants. Spending\n<accent>Balance</accent> From 6af900c440c04fab73da882853e90cbbbcfb3401 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 27 Feb 2026 08:26:08 -0300 Subject: [PATCH 17/27] fix: show dynamic duration for force-close --- .../java/to/bitkit/ui/components/IncomingTransfer.kt | 9 ++++++++- .../to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/IncomingTransfer.kt b/app/src/main/java/to/bitkit/ui/components/IncomingTransfer.kt index 489f312c4..92687e699 100644 --- a/app/src/main/java/to/bitkit/ui/components/IncomingTransfer.kt +++ b/app/src/main/java/to/bitkit/ui/components/IncomingTransfer.kt @@ -24,9 +24,16 @@ import to.bitkit.ui.utils.withAccent @Composable fun IncomingTransfer( amount: ULong, + remainingDuration: String? = null, modifier: Modifier = Modifier, currencies: CurrencyState = LocalCurrencies.current, ) { + val subtitle = if (remainingDuration != null) { + stringResource(R.string.wallet__activity_transfer_savings_pending) + .replace("{duration}", "±$remainingDuration") + } else { + stringResource(R.string.wallet__details_transfer_subtitle) + } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -39,7 +46,7 @@ fun IncomingTransfer( tint = Colors.White64, ) CaptionB( - text = stringResource(R.string.wallet__details_transfer_subtitle), + text = subtitle, color = Colors.White64, ) CaptionB( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt index 7b2b48671..cefc2f981 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt @@ -102,7 +102,8 @@ fun SavingsWalletScreen( if (balances.balanceInTransferToSavings > 0u) { IncomingTransfer( amount = balances.balanceInTransferToSavings, - modifier = Modifier.padding(vertical = 8.dp) + remainingDuration = balances.forceCloseRemainingDuration, + modifier = Modifier.padding(vertical = 8.dp), ) } From ae0f027bc3f480af2bdc782211b38d87bb71a795 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 27 Feb 2026 08:27:52 -0300 Subject: [PATCH 18/27] fix: suppress receive sheet for transfers --- app/src/main/java/to/bitkit/services/CoreService.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index e986bf65b..92342fb40 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -1046,6 +1046,16 @@ class ActivityService( runCatching { val onchain = getOnchainActivityByTxId(txid) ?: return@background true + if (onchain.isTransfer) { + Logger.info("Skipping received sheet for transfer transaction $txid", context = TAG) + return@background false + } + + if (onchain.channelId != null) { + Logger.info("Skipping received sheet for channel transaction $txid", context = TAG) + return@background false + } + // Check if activity has already been seen if (onchain.seenAt != null) { Logger.info( From 78efaf51b2e9bc95e7e1e761fba58b0dfac2bdb5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 27 Feb 2026 08:28:43 -0300 Subject: [PATCH 19/27] fix: claimableAtHeight type --- app/src/main/java/to/bitkit/data/entities/TransferEntity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/data/entities/TransferEntity.kt b/app/src/main/java/to/bitkit/data/entities/TransferEntity.kt index 4dd48ef40..6203f32ab 100644 --- a/app/src/main/java/to/bitkit/data/entities/TransferEntity.kt +++ b/app/src/main/java/to/bitkit/data/entities/TransferEntity.kt @@ -17,5 +17,5 @@ data class TransferEntity( val isSettled: Boolean = false, val createdAt: Long, val settledAt: Long? = null, - val claimableAtHeight: UInt? = null, + val claimableAtHeight: Int? = null, ) From 54804921d79ab567aa5f409d75e359d34c355b2d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 27 Feb 2026 08:28:52 -0300 Subject: [PATCH 20/27] chore: migration --- app/schemas/to.bitkit.data.AppDb/6.json | 98 +++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 app/schemas/to.bitkit.data.AppDb/6.json diff --git a/app/schemas/to.bitkit.data.AppDb/6.json b/app/schemas/to.bitkit.data.AppDb/6.json new file mode 100644 index 000000000..b27e9384b --- /dev/null +++ b/app/schemas/to.bitkit.data.AppDb/6.json @@ -0,0 +1,98 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "3be81070b5bbc85b549a246ad16af16d", + "entities": [ + { + "tableName": "config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletIndex` INTEGER NOT NULL, PRIMARY KEY(`walletIndex`))", + "fields": [ + { + "fieldPath": "walletIndex", + "columnName": "walletIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletIndex" + ] + } + }, + { + "tableName": "transfers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `type` TEXT NOT NULL, `amountSats` INTEGER NOT NULL, `channelId` TEXT, `fundingTxId` TEXT, `lspOrderId` TEXT, `isSettled` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `settledAt` INTEGER, `claimableAtHeight` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountSats", + "columnName": "amountSats", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channelId", + "columnName": "channelId", + "affinity": "TEXT" + }, + { + "fieldPath": "fundingTxId", + "columnName": "fundingTxId", + "affinity": "TEXT" + }, + { + "fieldPath": "lspOrderId", + "columnName": "lspOrderId", + "affinity": "TEXT" + }, + { + "fieldPath": "isSettled", + "columnName": "isSettled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "settledAt", + "columnName": "settledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "claimableAtHeight", + "columnName": "claimableAtHeight", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3be81070b5bbc85b549a246ad16af16d')" + ] + } +} \ No newline at end of file From 37235c3120d22483b3103e46708ec6ecbb9de3ed Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 27 Feb 2026 08:30:54 -0300 Subject: [PATCH 21/27] fix: claimableAtHeight type --- app/src/main/java/to/bitkit/repositories/TransferRepo.kt | 2 +- .../main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index 8357393a4..2d47199bc 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -55,7 +55,7 @@ class TransferRepo @Inject constructor( lspOrderId = lspOrderId, isSettled = false, createdAt = clock.now().epochSeconds, - claimableAtHeight = claimableAtHeight, + claimableAtHeight = claimableAtHeight?.toInt(), ) ) Logger.info("Created transfer: id=$id type=$type channelId=$channelId", context = TAG) diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index 5e395330a..33e1a70ca 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -123,7 +123,7 @@ class DeriveBalanceStateUseCase @Inject constructor( private fun getForceCloseRemainingDuration(transfers: List): String? { val forceClose = transfers.firstOrNull { it.type == TransferType.FORCE_CLOSE } ?: return null - val targetHeight = forceClose.claimableAtHeight ?: return null + val targetHeight = forceClose.claimableAtHeight?.toUInt() ?: return null val currentHeight = lightningRepo.lightningState.value.block()?.height ?: return null val remaining = BlockTimeHelpers.blocksRemaining(targetHeight, currentHeight) if (remaining <= 0) return null From dafdea3c89c705348a81d82900f98bdd63e0bcf1 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 27 Feb 2026 08:31:21 -0300 Subject: [PATCH 22/27] test: BlockTimer helper tests --- .../to/bitkit/utils/BlockTimeHelpersTest.kt | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 app/src/test/java/to/bitkit/utils/BlockTimeHelpersTest.kt diff --git a/app/src/test/java/to/bitkit/utils/BlockTimeHelpersTest.kt b/app/src/test/java/to/bitkit/utils/BlockTimeHelpersTest.kt new file mode 100644 index 000000000..f2ecc5285 --- /dev/null +++ b/app/src/test/java/to/bitkit/utils/BlockTimeHelpersTest.kt @@ -0,0 +1,42 @@ +package to.bitkit.utils + +import org.junit.Test +import kotlin.test.assertEquals + +class BlockTimeHelpersTest { + + @Test + fun `blocksRemaining returns positive difference`() { + assertEquals(6, BlockTimeHelpers.blocksRemaining(106u, 100u)) + } + + @Test + fun `blocksRemaining returns zero when target equals current`() { + assertEquals(0, BlockTimeHelpers.blocksRemaining(100u, 100u)) + } + + @Test + fun `blocksRemaining returns zero when target is below current`() { + assertEquals(0, BlockTimeHelpers.blocksRemaining(95u, 100u)) + } + + @Test + fun `getDurationForBlocks returns minutes for 6 or fewer blocks`() { + assertEquals("60m", BlockTimeHelpers.getDurationForBlocks(6)) + assertEquals("10m", BlockTimeHelpers.getDurationForBlocks(1)) + assertEquals("30m", BlockTimeHelpers.getDurationForBlocks(3)) + } + + @Test + fun `getDurationForBlocks returns hours for 7 to 143 blocks`() { + assertEquals("1h", BlockTimeHelpers.getDurationForBlocks(7)) + assertEquals("12h", BlockTimeHelpers.getDurationForBlocks(72)) + assertEquals("24h", BlockTimeHelpers.getDurationForBlocks(143)) + } + + @Test + fun `getDurationForBlocks returns days for more than 143 blocks`() { + assertEquals("1d", BlockTimeHelpers.getDurationForBlocks(144)) + assertEquals("7d", BlockTimeHelpers.getDurationForBlocks(1008)) + } +} From aad07e44873bbbd74a0a0fa9f3b60492846e6ccd Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 27 Feb 2026 08:47:33 -0300 Subject: [PATCH 23/27] chore: lint --- .../main/java/to/bitkit/repositories/TransferRepo.kt | 1 + .../to/bitkit/ui/screens/wallets/HomeViewModel.kt | 6 +++--- .../to/bitkit/usecases/DeriveBalanceStateUseCase.kt | 2 +- app/src/main/java/to/bitkit/utils/BlockTimeHelpers.kt | 11 ++++++++--- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index 2d47199bc..8687b6b73 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -35,6 +35,7 @@ class TransferRepo @Inject constructor( ) { val activeTransfers: Flow> = transferDao.getActiveTransfers() + @Suppress("LongParameterList") suspend fun createTransfer( type: TransferType, amountSats: Long, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt index c3d01fe70..3198bd659 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt @@ -1,8 +1,10 @@ package to.bitkit.ui.screens.wallets +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -11,10 +13,8 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import to.bitkit.data.SettingsStore -import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext import to.bitkit.R +import to.bitkit.data.SettingsStore import to.bitkit.models.ActivityBannerType import to.bitkit.models.BannerItem import to.bitkit.models.Suggestion diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index 33e1a70ca..fef44b0e9 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -8,12 +8,12 @@ import to.bitkit.data.entities.TransferEntity import to.bitkit.ext.amountSats import to.bitkit.ext.channelId import to.bitkit.ext.totalNextOutboundHtlcLimitSats -import to.bitkit.utils.BlockTimeHelpers import to.bitkit.models.BalanceState import to.bitkit.models.TransferType import to.bitkit.models.safe import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo +import to.bitkit.utils.BlockTimeHelpers import to.bitkit.utils.Logger import to.bitkit.utils.jsonLogOf import javax.inject.Inject diff --git a/app/src/main/java/to/bitkit/utils/BlockTimeHelpers.kt b/app/src/main/java/to/bitkit/utils/BlockTimeHelpers.kt index 00f5cdbc7..46fd32b4f 100644 --- a/app/src/main/java/to/bitkit/utils/BlockTimeHelpers.kt +++ b/app/src/main/java/to/bitkit/utils/BlockTimeHelpers.kt @@ -2,15 +2,20 @@ package to.bitkit.utils import kotlin.math.roundToInt +@Suppress("MagicNumber") object BlockTimeHelpers { private const val BLOCK_TIME_MINUTES = 10 + private const val MINUTES_PER_HOUR = 60.0 + private const val HOURS_PER_DAY = 24.0 + private const val BLOCKS_PER_DAY = 143 + private const val REORG_PROTECTION_BLOCKS = 6 fun getDurationForBlocks(blocks: Int): String = when { - blocks > 143 -> "${(blocks * BLOCK_TIME_MINUTES / 60.0 / 24.0).roundToInt()}d" - blocks > 6 -> "${(blocks * BLOCK_TIME_MINUTES / 60.0).roundToInt()}h" + blocks > BLOCKS_PER_DAY -> "${(blocks * BLOCK_TIME_MINUTES / MINUTES_PER_HOUR / HOURS_PER_DAY).roundToInt()}d" + blocks > REORG_PROTECTION_BLOCKS -> "${(blocks * BLOCK_TIME_MINUTES / MINUTES_PER_HOUR).roundToInt()}h" else -> "${blocks * BLOCK_TIME_MINUTES}m" } fun blocksRemaining(targetHeight: UInt, currentHeight: UInt): Int = maxOf(0, (targetHeight.toInt() - currentHeight.toInt())) -} \ No newline at end of file +} From c1715b4e29fa7efd2a300524522896187a7dffc5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 27 Feb 2026 08:57:34 -0300 Subject: [PATCH 24/27] refactor: move force close duration calculation to TransferRepo.kt --- .../main/java/to/bitkit/models/BalanceState.kt | 2 -- .../java/to/bitkit/repositories/TransferRepo.kt | 15 +++++++++++++++ app/src/main/java/to/bitkit/ui/ContentView.kt | 2 ++ .../to/bitkit/ui/screens/wallets/HomeViewModel.kt | 6 +++--- .../ui/screens/wallets/SavingsWalletScreen.kt | 3 ++- .../bitkit/usecases/DeriveBalanceStateUseCase.kt | 14 -------------- .../java/to/bitkit/viewmodels/AppViewModel.kt | 3 +++ 7 files changed, 25 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/BalanceState.kt b/app/src/main/java/to/bitkit/models/BalanceState.kt index 60635bf9f..07cfc7a23 100644 --- a/app/src/main/java/to/bitkit/models/BalanceState.kt +++ b/app/src/main/java/to/bitkit/models/BalanceState.kt @@ -1,7 +1,6 @@ package to.bitkit.models import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient @Serializable data class BalanceState( @@ -11,7 +10,6 @@ data class BalanceState( val maxSendOnchainSats: ULong = 0uL, val balanceInTransferToSavings: ULong = 0uL, val balanceInTransferToSpending: ULong = 0uL, - @Transient val forceCloseRemainingDuration: String? = null, ) { val totalSats get() = totalOnchainSats + totalLightningSats } diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index 8687b6b73..dcd06284b 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -5,6 +5,7 @@ import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.SortDirection import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import org.lightningdevkit.ldknode.ChannelDetails @@ -16,6 +17,7 @@ import to.bitkit.ext.channelId import to.bitkit.ext.latestSpendingTxid import to.bitkit.models.TransferType import to.bitkit.services.CoreService +import to.bitkit.utils.BlockTimeHelpers import to.bitkit.utils.Logger import java.util.UUID import javax.inject.Inject @@ -35,6 +37,19 @@ class TransferRepo @Inject constructor( ) { val activeTransfers: Flow> = transferDao.getActiveTransfers() + val forceCloseRemainingDuration: Flow = combine( + activeTransfers, + lightningRepo.lightningState, + ) { transfers, lightningState -> + val forceClose = transfers.firstOrNull { it.type == TransferType.FORCE_CLOSE } + ?: return@combine null + val targetHeight = forceClose.claimableAtHeight?.toUInt() ?: return@combine null + val currentHeight = lightningState.block()?.height ?: return@combine null + val remaining = BlockTimeHelpers.blocksRemaining(targetHeight, currentHeight) + if (remaining <= 0) return@combine null + BlockTimeHelpers.getDurationForBlocks(remaining) + } + @Suppress("LongParameterList") suspend fun createTransfer( type: TransferType, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 4663a31fc..830bcd9f4 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -809,6 +809,7 @@ private fun NavGraphBuilder.home( val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val isGeoBlocked by appViewModel.isGeoBlocked.collectAsStateWithLifecycle() val onchainActivities by activityListViewModel.onchainActivities.collectAsStateWithLifecycle() + val forceCloseRemainingDuration by appViewModel.forceCloseRemainingDuration.collectAsStateWithLifecycle() SavingsWalletScreen( isGeoBlocked = isGeoBlocked, @@ -824,6 +825,7 @@ private fun NavGraphBuilder.home( } }, onBackClick = { navController.popBackStack() }, + forceCloseRemainingDuration = forceCloseRemainingDuration, ) } composable( diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt index 3198bd659..5141ae83f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt @@ -222,10 +222,10 @@ class HomeViewModel @Inject constructor( private suspend fun createBannersFlow() { combine( transferRepo.activeTransfers, - walletRepo.balanceState, - ) { transfers, balanceState -> + transferRepo.forceCloseRemainingDuration, + ) { transfers, remainingDuration -> val defaultTitle = context.getString(R.string.lightning__transfer_in_progress) - val savingsTitle = balanceState.forceCloseRemainingDuration?.let { + val savingsTitle = remainingDuration?.let { context.getString(R.string.lightning__transfer_ready_in, it) } ?: defaultTitle diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt index cefc2f981..7b7e32433 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt @@ -52,6 +52,7 @@ fun SavingsWalletScreen( onActivityItemClick: (String) -> Unit, onTransferToSpendingClick: () -> Unit, onBackClick: () -> Unit, + forceCloseRemainingDuration: String? = null, balances: BalanceState = LocalBalances.current, ) { val showEmptyState by remember(balances.totalOnchainSats, onchainActivities.size) { @@ -102,7 +103,7 @@ fun SavingsWalletScreen( if (balances.balanceInTransferToSavings > 0u) { IncomingTransfer( amount = balances.balanceInTransferToSavings, - remainingDuration = balances.forceCloseRemainingDuration, + remainingDuration = forceCloseRemainingDuration, modifier = Modifier.padding(vertical = 8.dp), ) } diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index fef44b0e9..bffc53fcf 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -13,7 +13,6 @@ import to.bitkit.models.TransferType import to.bitkit.models.safe import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo -import to.bitkit.utils.BlockTimeHelpers import to.bitkit.utils.Logger import to.bitkit.utils.jsonLogOf import javax.inject.Inject @@ -40,8 +39,6 @@ class DeriveBalanceStateUseCase @Inject constructor( val afterPendingChannels = balanceDetails.totalLightningBalanceSats.safe() - pendingChannelsSats.safe() val totalLightningSats = afterPendingChannels.safe() - toSavingsAmount.safe() - val forceCloseRemainingDuration = getForceCloseRemainingDuration(activeTransfers) - val balanceState = BalanceState( totalOnchainSats = totalOnchainSats, totalLightningSats = totalLightningSats, @@ -49,7 +46,6 @@ class DeriveBalanceStateUseCase @Inject constructor( maxSendOnchainSats = getMaxSendAmount(balanceDetails), balanceInTransferToSavings = toSavingsAmount, balanceInTransferToSpending = toSpendingAmount, - forceCloseRemainingDuration = forceCloseRemainingDuration, ) val height = lightningRepo.lightningState.value.block()?.height @@ -120,16 +116,6 @@ class DeriveBalanceStateUseCase @Inject constructor( return spendableOnchainSats.safe() - fee.safe() } - private fun getForceCloseRemainingDuration(transfers: List): String? { - val forceClose = transfers.firstOrNull { it.type == TransferType.FORCE_CLOSE } - ?: return null - val targetHeight = forceClose.claimableAtHeight?.toUInt() ?: return null - val currentHeight = lightningRepo.lightningState.value.block()?.height ?: return null - val remaining = BlockTimeHelpers.blocksRemaining(targetHeight, currentHeight) - if (remaining <= 0) return null - return BlockTimeHelpers.getDurationForBlocks(remaining) - } - companion object { const val TAG = "DeriveBalanceStateUseCase" const val FALLBACK_FEE_PERCENT = 0.1 diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index bb8dade8f..00ece95a3 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -176,6 +176,9 @@ class AppViewModel @Inject constructor( val isGeoBlocked = lightningRepo.lightningState.map { it.isGeoBlocked } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val forceCloseRemainingDuration = transferRepo.forceCloseRemainingDuration + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + private val _sendUiState = MutableStateFlow(SendUiState()) val sendUiState = _sendUiState.asStateFlow() From 94c86a332dc4dd36175ab0f0d4c8f0031fb16da0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 27 Feb 2026 10:50:40 -0300 Subject: [PATCH 25/27] fix: subtract coop closing amount from balanceInTransferToSavings --- .../to/bitkit/repositories/TransferRepo.kt | 6 ---- .../java/to/bitkit/repositories/WalletRepo.kt | 2 ++ .../usecases/DeriveBalanceStateUseCase.kt | 19 +++++++++-- .../bitkit/repositories/TransferRepoTest.kt | 34 +++++++++++++++++-- .../to/bitkit/repositories/WalletRepoTest.kt | 2 ++ .../usecases/DeriveBalanceStateUseCaseTest.kt | 9 ++--- 6 files changed, 58 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index dcd06284b..48a64f400 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -117,12 +117,6 @@ class TransferRepo @Inject constructor( val toSavings = activeTransfers.filter { it.type.isToSavings() } for (transfer in toSavings) { - if (transfer.type == TransferType.COOP_CLOSE) { - markSettled(transfer.id) - Logger.debug("Coop close settled immediately: ${transfer.id}", context = TAG) - continue - } - val channelId = resolveChannelIdForTransfer(transfer, channels) val hasBalance = balances?.lightningBalances?.any { it.channelId() == channelId diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index 3b5782db4..176dcee2d 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -52,6 +52,7 @@ class WalletRepo @Inject constructor( private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase, private val wipeWalletUseCase: WipeWalletUseCase, private val transferRepo: TransferRepo, + private val activityRepo: ActivityRepo, ) { private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) @@ -204,6 +205,7 @@ class WalletRepo @Inject constructor( delay(EVENT_SYNC_DEBOUNCE_MS) syncBalances() transferRepo.syncTransferStates() + activityRepo.syncActivities() } } diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index bffc53fcf..34165fed7 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -33,6 +33,7 @@ class DeriveBalanceStateUseCase @Inject constructor( val pendingChannelsSats = getPendingChannelsSats(activeTransfers, channels, balanceDetails) val toSavingsAmount = getTransferToSavingsSats(activeTransfers, channels, balanceDetails) + val coopCloseSavingsSats = getCoopCloseTransferSats(activeTransfers, channels, balanceDetails) val toSpendingAmount = paidOrdersSats.safe() + pendingChannelsSats.safe() val totalOnchainSats = balanceDetails.totalOnchainBalanceSats @@ -44,7 +45,7 @@ class DeriveBalanceStateUseCase @Inject constructor( totalLightningSats = totalLightningSats, maxSendLightningSats = lightningRepo.getChannels().totalNextOutboundHtlcLimitSats(), maxSendOnchainSats = getMaxSendAmount(balanceDetails), - balanceInTransferToSavings = toSavingsAmount, + balanceInTransferToSavings = toSavingsAmount.safe() - coopCloseSavingsSats.safe(), balanceInTransferToSpending = toSpendingAmount, ) @@ -87,7 +88,7 @@ class DeriveBalanceStateUseCase @Inject constructor( balanceDetails: BalanceDetails, ): ULong { var toSavingsAmount = 0uL - val toSavings = transfers.filter { it.type.isToSavings() && it.type != TransferType.COOP_CLOSE } + val toSavings = transfers.filter { it.type.isToSavings() } for (transfer in toSavings) { val channelId = transferRepo.resolveChannelIdForTransfer(transfer, channels) @@ -98,6 +99,20 @@ class DeriveBalanceStateUseCase @Inject constructor( return toSavingsAmount } + private suspend fun getCoopCloseTransferSats( + transfers: List, + channels: List, + balanceDetails: BalanceDetails, + ): ULong { + var amount = 0uL + for (transfer in transfers.filter { it.type == TransferType.COOP_CLOSE }) { + val channelId = transferRepo.resolveChannelIdForTransfer(transfer, channels) + val channelBalance = balanceDetails.lightningBalances.find { it.channelId() == channelId } + amount += channelBalance?.amountSats() ?: 0u + } + 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 f30d2d280..68f699344 100644 --- a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt @@ -461,8 +461,7 @@ class TransferRepoTest : BaseUnitTest() { } @Test - fun `syncTransferStates settles COOP_CLOSE immediately without waiting for balance sweep`() = test { - val settledAt = setupClockNowMock() + fun `syncTransferStates does not settle COOP_CLOSE while LDK balance exists`() = test { val transfer = TransferEntity( id = ID_TRANSFER, type = TransferType.COOP_CLOSE, @@ -489,6 +488,37 @@ class TransferRepoTest : BaseUnitTest() { pendingBalancesFromChannelClosures = emptyList(), ) + whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer))) + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) + + 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() + val transfer = TransferEntity( + id = ID_TRANSFER, + type = TransferType.COOP_CLOSE, + amountSats = 75000L, + channelId = ID_CHANNEL, + isSettled = false, + createdAt = 1000L, + ) + + val balances = BalanceDetails( + totalOnchainBalanceSats = 75000u, + spendableOnchainBalanceSats = 75000u, + totalAnchorChannelsReserveSats = 0u, + totalLightningBalanceSats = 0u, + lightningBalances = emptyList(), + pendingBalancesFromChannelClosures = emptyList(), + ) + whenever(transferDao.getActiveTransfers()).thenReturn(flowOf(listOf(transfer))) whenever(lightningRepo.getChannels()).thenReturn(emptyList()) whenever(lightningRepo.getBalancesAsync()).thenReturn(Result.success(balances)) diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index c0b4e19d4..5ab02c682 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -45,6 +45,7 @@ class WalletRepoTest : BaseUnitTest() { private val deriveBalanceStateUseCase = mock() private val wipeWalletUseCase = mock() private val transferRepo = mock() + private val activityRepo = mock() companion object Fixtures { const val ACTIVITY_TAG = "testTag" @@ -109,6 +110,7 @@ class WalletRepoTest : BaseUnitTest() { deriveBalanceStateUseCase = deriveBalanceStateUseCase, wipeWalletUseCase = wipeWalletUseCase, transferRepo = transferRepo, + activityRepo = activityRepo, ) @Test diff --git a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt index d7a438134..bb00ff383 100644 --- a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt @@ -193,7 +193,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { } @Test - fun `should not count coop close channel balance for transfer to savings`() = test { + fun `should subtract coop close balance from lightning without showing transfer in progress`() = test { val channelId = "closing-channel-id" val amountSats = 40_000uL val closingChannelBalance = newClosingChannelBalance(channelId, amountSats) @@ -215,16 +215,17 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { whenever(lightningRepo.getChannels()).thenReturn(emptyList()) whenever(transferRepo.activeTransfers).thenReturn(flowOf(transfers)) + wheneverBlocking { transferRepo.resolveChannelIdForTransfer(any(), any()) }.thenReturn(channelId) val result = sut() assertTrue(result.isSuccess) val balanceState = result.getOrThrow() - assertEquals(0uL, balanceState.balanceInTransferToSavings) + assertEquals(0uL, balanceState.balanceInTransferToSavings, "No transfer in progress for coop close") assertEquals( - amountSats, + 0uL, balanceState.totalLightningSats, - "Lightning balance not reduced - coop close funds are immediately spendable" + "Lightning balance reduced - coop close balance subtracted" ) } From 8302993925aab6be80d0ead07d3c972a5b046f71 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 27 Feb 2026 11:06:16 -0300 Subject: [PATCH 26/27] fix: transfer banner visibility --- .../java/to/bitkit/ui/screens/wallets/HomeViewModel.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt index 5141ae83f..a2e6e563d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt @@ -221,9 +221,9 @@ class HomeViewModel @Inject constructor( private suspend fun createBannersFlow() { combine( - transferRepo.activeTransfers, + walletRepo.balanceState, transferRepo.forceCloseRemainingDuration, - ) { transfers, remainingDuration -> + ) { balanceState, remainingDuration -> val defaultTitle = context.getString(R.string.lightning__transfer_in_progress) val savingsTitle = remainingDuration?.let { context.getString(R.string.lightning__transfer_ready_in, it) @@ -233,11 +233,11 @@ class HomeViewModel @Inject constructor( BannerItem( type = ActivityBannerType.SPENDING, title = defaultTitle, - ).takeIf { transfers.any { it.type.isToSpending() } }, + ).takeIf { balanceState.balanceInTransferToSpending > 0uL }, BannerItem( type = ActivityBannerType.SAVINGS, title = savingsTitle, - ).takeIf { transfers.any { it.type.isToSavings() } }, + ).takeIf { balanceState.balanceInTransferToSavings > 0uL }, ) }.collect { banners -> _uiState.update { it.copy(banners = banners) } From ed56df95fb9e63b0f7128cbd138a674016c79b5c Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 27 Feb 2026 11:56:28 -0300 Subject: [PATCH 27/27] fix: check if this transaction is a pending sweep from a channel closure --- .../java/to/bitkit/services/CoreService.kt | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 92342fb40..7c097e158 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -71,7 +71,9 @@ import to.bitkit.async.ServiceQueue import to.bitkit.data.CacheStore import to.bitkit.env.Env import to.bitkit.ext.amountSats +import to.bitkit.ext.channelId import to.bitkit.ext.create +import to.bitkit.ext.latestSpendingTxid import to.bitkit.models.toCoreNetwork import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -1257,10 +1259,13 @@ class ActivityService( transactionDetails: BitkitCoreTransactionDetails? = null, ): String? { return runCatching { + // Check if this transaction is a pending sweep from a channel closure + val pendingSweeps = lightningService.balances?.pendingBalancesFromChannelClosures + val matchingSweep = pendingSweeps?.firstOrNull { it.latestSpendingTxid() == txid } + if (matchingSweep != null) return matchingSweep.channelId() + val closedChannelsList = closedChannels(SortDirection.DESC) - if (closedChannelsList.isEmpty()) { - return null - } + if (closedChannelsList.isEmpty()) return null // Use provided transaction details if available, otherwise fetch from bitkitcore val details = transactionDetails ?: fetchTransactionDetails(txid) ?: run { @@ -1268,24 +1273,39 @@ class ActivityService( return null } - for (input in details.inputs) { - val inputTxid = input.txid - val inputVout = input.vout.toInt() - - val matchingChannel = closedChannelsList.firstOrNull { channel -> - channel.fundingTxoTxid == inputTxid && channel.fundingTxoIndex == inputVout.toUInt() - } - - if (matchingChannel != null) { - return matchingChannel.channelId - } - } - null + // Check if any input spends a closed channel's funding UTXO (commitment tx) + findChannelByFundingUtxo(details, closedChannelsList) + // Check if any input's parent transaction is a channel-related activity + // (e.g., sweep tx spending from commitment tx) + ?: findChannelByParentActivity(details) }.onFailure { e -> Logger.warn("Failed to check if transaction $txid spends closed channel funding UTXO", e, context = TAG) }.getOrNull() } + private fun findChannelByFundingUtxo( + details: BitkitCoreTransactionDetails, + closedChannels: List, + ): String? { + for (input in details.inputs) { + val matchingChannel = closedChannels.firstOrNull { channel -> + channel.fundingTxoTxid == input.txid && channel.fundingTxoIndex == input.vout + } + if (matchingChannel != null) return matchingChannel.channelId + } + return null + } + + private suspend fun findChannelByParentActivity( + details: BitkitCoreTransactionDetails, + ): String? { + for (input in details.inputs) { + val parentActivity = getOnchainActivityByTxId(input.txid) + if (parentActivity?.channelId != null) return parentActivity.channelId + } + return null + } + companion object { private const val TAG = "ActivityService" }