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 + } +} diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt index 6aa2e8708..26b7d4dc9 100644 --- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt @@ -1,15 +1,21 @@ 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 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 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 +29,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, ) { @@ -92,14 +99,27 @@ 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 } ?: 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 +129,72 @@ class TransferRepo @Inject constructor( } } + private suspend fun settleForceClose( + transfer: TransferEntity, + channelId: String?, + pendingSweeps: List?, + ) { + 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 + } + + // 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. + markActivityAsTransfer(sweepTxid, channelId) + 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) + } + + 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/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, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 45ae8f1d5..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 @@ -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..c9a1a3e79 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/ConnectionClosedSheet.kt @@ -0,0 +1,95 @@ +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.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 +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( + size = SheetSize.MEDIUM + ) + .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/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/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index f0d371c8c..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 @@ -47,6 +48,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 +67,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 @@ -85,6 +89,7 @@ import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.Suggestion import to.bitkit.models.Toast 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 @@ -319,7 +324,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 +362,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(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 @@ -2230,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), "") 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. diff --git a/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TransferRepoTest.kt index 59ac8dae1..f30d2d280 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 @@ -12,7 +15,9 @@ 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.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -21,8 +26,11 @@ 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 +import to.bitkit.services.CoreService import to.bitkit.test.BaseUnitTest import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -31,6 +39,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 @@ -38,6 +47,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 +68,7 @@ class TransferRepoTest : BaseUnitTest() { bgDispatcher = testDispatcher, lightningRepo = lightningRepo, blocktankRepo = blocktankRepo, + coreService = coreService, transferDao = transferDao, clock = clock, ) @@ -446,6 +460,237 @@ 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 + 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 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, + 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( + 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 + 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 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, + 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(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 @Test @@ -629,6 +874,7 @@ class TransferRepoTest : BaseUnitTest() { bgDispatcher = testDispatcher, lightningRepo = lightningRepo, blocktankRepo = blocktankRepo, + coreService = coreService, transferDao = transferDao, clock = clock, ) 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