diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index fc049e226..8543b36f2 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -1,5 +1,7 @@ package to.bitkit.services +import com.synonym.bitkitcore.ClosedChannelDetails +import com.synonym.bitkitcore.upsertClosedChannel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay @@ -49,6 +51,7 @@ import to.bitkit.utils.LdkError import to.bitkit.utils.LdkLogWriter import to.bitkit.utils.Logger import to.bitkit.utils.ServiceError +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton import kotlin.io.path.Path @@ -57,6 +60,7 @@ import kotlin.time.Duration.Companion.seconds typealias NodeEventHandler = suspend (Event) -> Unit +@Suppress("LargeClass") @Singleton class LightningService @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, @@ -70,6 +74,8 @@ class LightningService @Inject constructor( private lateinit var trustedPeers: List + private val channelCache = ConcurrentHashMap() + suspend fun setup( walletIndex: Int, customServerUrl: String? = null, @@ -190,6 +196,7 @@ class LightningService @Inject constructor( } } + refreshChannelCache() Logger.info("Node started") } @@ -225,9 +232,79 @@ class LightningService @Inject constructor( node.syncWallets() // launch { setMaxDustHtlcExposureForCurrentChannels() } } + refreshChannelCache() + Logger.debug("LDK synced") } + private suspend fun refreshChannelCache() { + val node = this.node ?: return + + ServiceQueue.LDK.background { + val channels = node.listChannels() + channels.forEach { channel -> + channelCache[channel.channelId] = channel + } + } + } + + private suspend fun registerClosedChannel(channelId: String, reason: String?) { + try { + val channel = ServiceQueue.LDK.background { + channelCache[channelId] + } ?: run { + Logger.error( + "Could not find channel details for closed channel: channelId=$channelId", + context = TAG + ) + return@registerClosedChannel + } + + val fundingTxo = channel.fundingTxo + if (fundingTxo == null) { + Logger.error( + "Channel has no funding transaction, cannot persist closed channel: channelId=$channelId", + context = TAG + ) + return@registerClosedChannel + } + + val channelName = channel.inboundScidAlias?.toString() + ?: channel.channelId.take(CHANNEL_ID_PREVIEW_LENGTH) + "…" + + val closedAt = (System.currentTimeMillis() / 1000L).toULong() + + val closedChannel = ClosedChannelDetails( + channelId = channel.channelId, + counterpartyNodeId = channel.counterpartyNodeId, + fundingTxoTxid = fundingTxo.txid, + fundingTxoIndex = fundingTxo.vout, + channelValueSats = channel.channelValueSats, + closedAt = closedAt, + outboundCapacityMsat = channel.outboundCapacityMsat, + inboundCapacityMsat = channel.inboundCapacityMsat, + counterpartyUnspendablePunishmentReserve = channel.counterpartyUnspendablePunishmentReserve, + unspendablePunishmentReserve = channel.unspendablePunishmentReserve ?: 0u, + forwardingFeeProportionalMillionths = channel.config.forwardingFeeProportionalMillionths, + forwardingFeeBaseMsat = channel.config.forwardingFeeBaseMsat, + channelName = channelName, + channelClosureReason = reason.orEmpty() + ) + + ServiceQueue.CORE.background { + upsertClosedChannel(closedChannel) + } + + ServiceQueue.LDK.background { + channelCache.remove(channelId) + } + + Logger.info("Registered closed channel: ${channel.userChannelId}", context = TAG) + } catch (e: Exception) { + Logger.error("Failed to register closed channel: $e", e, context = TAG) + } + } + // private fun setMaxDustHtlcExposureForCurrentChannels() { // if (Env.network != Network.REGTEST) { // Logger.debug("Not updating channel config for non-regtest network") @@ -738,6 +815,9 @@ class LightningService @Inject constructor( Logger.info( "⏳ Channel pending: channelId: $channelId userChannelId: $userChannelId formerTemporaryChannelId: $formerTemporaryChannelId counterpartyNodeId: $counterpartyNodeId fundingTxo: $fundingTxo" ) + launch { + refreshChannelCache() + } } is Event.ChannelReady -> { @@ -747,16 +827,22 @@ class LightningService @Inject constructor( Logger.info( "👐 Channel ready: channelId: $channelId userChannelId: $userChannelId counterpartyNodeId: $counterpartyNodeId" ) + launch { + refreshChannelCache() + } } is Event.ChannelClosed -> { val channelId = event.channelId val userChannelId = event.userChannelId val counterpartyNodeId = event.counterpartyNodeId ?: "?" - val reason = event.reason + val reason = event.reason?.toString() ?: "" Logger.info( "⛔ Channel closed: channelId: $channelId userChannelId: $userChannelId counterpartyNodeId: $counterpartyNodeId reason: $reason" ) + launch { + registerClosedChannel(channelId, reason) + } } } } @@ -781,6 +867,7 @@ class LightningService @Inject constructor( companion object { private const val TAG = "LightningService" + private const val CHANNEL_ID_PREVIEW_LENGTH = 10 } } diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 47b01bd4c..371edfdb2 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -97,6 +97,8 @@ fun ChannelDetailScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val paidOrders by viewModel.blocktankRepo.blocktankState.collectAsStateWithLifecycle() + + val isClosedChannel = uiState.closedChannels.any { it.details.channelId == channel.details.channelId } val txDetails by viewModel.txDetails.collectAsStateWithLifecycle() val walletState by wallet.uiState.collectAsStateWithLifecycle() @@ -113,6 +115,7 @@ fun ChannelDetailScreen( cjitEntries = paidOrders.cjitEntries, txDetails = txDetails, isRefreshing = uiState.isRefreshing, + isClosedChannel = isClosedChannel, onBack = { navController.popBackStack() }, onClose = { navController.navigateToHome() }, onRefresh = { @@ -137,6 +140,7 @@ fun ChannelDetailScreen( } @OptIn(ExperimentalMaterial3Api::class) +@Suppress("CyclomaticComplexMethod") @Composable private fun Content( channel: ChannelUi, @@ -144,6 +148,7 @@ private fun Content( cjitEntries: List = emptyList(), txDetails: TxDetails? = null, isRefreshing: Boolean = false, + isClosedChannel: Boolean = false, onBack: () -> Unit = {}, onClose: () -> Unit = {}, onRefresh: () -> Unit = {}, @@ -201,7 +206,7 @@ private fun Content( capacity = capacity, localBalance = localBalance, remoteBalance = remoteBalance, - status = getChannelStatus(channel, blocktankOrder), + status = getChannelStatus(channel, blocktankOrder, isClosedChannel), ) VerticalSpacer(32.dp) HorizontalDivider() @@ -211,6 +216,7 @@ private fun Content( ChannelStatusView( channel = channel, blocktankOrder = blocktankOrder, + isClosedChannel = isClosedChannel, ) VerticalSpacer(16.dp) HorizontalDivider() @@ -447,16 +453,15 @@ private fun Content( onClick = { onCopyText(channel.details.counterpartyNodeId) } ) - // TODO add closure reason when tracking closed channels - // val channelClosureReason: String? = null - // if (channelClosureReason != null) { - // SectionRow( - // name = stringResource(R.string.lightning__closure_reason), - // valueContent = { - // CaptionB(text = channelClosureReason) - // }, - // ) - // } + // Closure reason for closed channels + channel.closureReason?.let { closureReason -> + SectionRow( + name = stringResource(R.string.lightning__closure_reason), + valueContent = { + CaptionB(text = closureReason) + }, + ) + } // Action Buttons FillHeight() @@ -526,7 +531,12 @@ private fun SectionRow( private fun getChannelStatus( channel: ChannelUi, blocktankOrder: IBtOrder?, + isClosedChannel: Boolean = false, ): ChannelStatusUi { + if (isClosedChannel) { + return ChannelStatusUi.CLOSED + } + blocktankOrder?.let { order -> when { order.state2 == BtOrderState2.EXPIRED || diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt index c5ba9a7e3..832c484f7 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext @@ -63,6 +64,8 @@ import to.bitkit.ui.shared.util.shareZipFile import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +private const val CLOSED_CHANNEL_ALPHA = 0.64f + object LightningConnectionsTestTags { const val SCREEN = "lightning_connections_screen" const val ADD_CONNECTION_BUTTON = "add_connection_button" @@ -188,8 +191,21 @@ private fun Content( } } + // Closed Channels Section + AnimatedVisibility(visible = showClosed && uiState.closedChannels.isNotEmpty()) { + Column { + VerticalSpacer(16.dp) + Caption13Up(stringResource(R.string.lightning__conn_closed), color = Colors.White64) + ChannelList( + status = ChannelStatusUi.CLOSED, + channels = uiState.closedChannels, + onClickChannel = onClickChannel, + ) + } + } + // Show/Hide Closed Channels Button - if (uiState.failedOrders.isNotEmpty()) { + if (uiState.failedOrders.isNotEmpty() || uiState.closedChannels.isNotEmpty()) { VerticalSpacer(16.dp) TertiaryButton( text = stringResource( @@ -295,6 +311,13 @@ private fun ChannelItem( .fillMaxWidth() .clickableAlpha { onClick() } .testTag("Channel") + .then( + if (status == ChannelStatusUi.CLOSED) { + Modifier.alpha(CLOSED_CHANNEL_ALPHA) + } else { + Modifier + } + ) ) { VerticalSpacer(16.dp) Row( diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index 97b2caaaa..092c05fc1 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt @@ -5,7 +5,9 @@ import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.BtOrderState2 +import com.synonym.bitkitcore.ClosedChannelDetails import com.synonym.bitkitcore.IBtOrder +import com.synonym.bitkitcore.SortDirection import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher @@ -25,6 +27,7 @@ import to.bitkit.ext.createChannelDetails import to.bitkit.ext.filterOpen import to.bitkit.ext.filterPending import to.bitkit.models.Toast +import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LogsRepo @@ -36,6 +39,7 @@ import to.bitkit.utils.Logger import to.bitkit.utils.TxDetails import javax.inject.Inject +@Suppress("LongParameterList") @HiltViewModel class LightningConnectionsViewModel @Inject constructor( @ApplicationContext private val context: Context, @@ -46,6 +50,7 @@ class LightningConnectionsViewModel @Inject constructor( private val addressChecker: AddressChecker, private val ldkNodeEventBus: LdkNodeEventBus, private val walletRepo: WalletRepo, + private val activityRepo: ActivityRepo, ) : ViewModel() { private val _uiState = MutableStateFlow(LightningConnectionsUiState()) @@ -63,6 +68,32 @@ class LightningConnectionsViewModel @Inject constructor( init { observeState() observeLdkEvents() + loadClosedChannels() + } + + private fun loadClosedChannels() { + viewModelScope.launch(bgDispatcher) { + activityRepo.getClosedChannels(SortDirection.DESC) + .onSuccess { closedChannels -> + val channels = lightningRepo.lightningState.value.channels + val openChannels = channels.filterOpen() + val pendingConnections = + getPendingConnections(channels, blocktankRepo.blocktankState.value.paidOrders) + + _uiState.update { state -> + state.copy( + closedChannels = closedChannels.mapIndexed { index, closedChannel -> + closedChannel.toChannelUi( + baseIndex = openChannels.size + pendingConnections.size + index + ) + }.reversed() + ) + } + } + .onFailure { e -> + Logger.error("Failed to load closed channels", e, context = TAG) + } + } } private fun observeState() { @@ -97,6 +128,9 @@ class LightningConnectionsViewModel @Inject constructor( if (event is Event.ChannelPending || event is Event.ChannelReady || event is Event.ChannelClosed) { Logger.debug("Channel event received: ${event::class.simpleName}, triggering refresh") refreshObservedState() + if (event is Event.ChannelClosed) { + loadClosedChannels() + } } } } @@ -104,8 +138,18 @@ class LightningConnectionsViewModel @Inject constructor( private fun refreshSelectedChannelIfNeeded(channels: List) { val currentSelectedChannel = _selectedChannel.value ?: return - val updatedChannel = findUpdatedChannel(currentSelectedChannel.details, channels) + // Filter out closed channels from the list + val closedChannelIds = _uiState.value.closedChannels.map { it.details.channelId }.toSet() + val activeChannels = channels.filterNot { it.channelId in closedChannelIds } + + // Don't refresh if the selected channel is closed + if (currentSelectedChannel.details.channelId in closedChannelIds) { + return + } + + // Try to find updated version in active channels + val updatedChannel = findUpdatedChannel(currentSelectedChannel.details, activeChannels) _selectedChannel.update { updatedChannel?.mapToUiModel() } } @@ -167,6 +211,28 @@ class LightningConnectionsViewModel @Inject constructor( suspend fun refreshObservedState() { lightningRepo.sync() blocktankRepo.refreshOrders() + loadClosedChannels() + } + + private fun ClosedChannelDetails.toChannelUi(baseIndex: Int): ChannelUi { + val channelDetails = createChannelDetails().copy( + channelId = this.channelId, + counterpartyNodeId = this.counterpartyNodeId, + fundingTxo = OutPoint(txid = this.fundingTxoTxid, vout = this.fundingTxoIndex), + channelValueSats = this.channelValueSats, + outboundCapacityMsat = this.outboundCapacityMsat, + inboundCapacityMsat = this.inboundCapacityMsat, + unspendablePunishmentReserve = this.unspendablePunishmentReserve, + counterpartyUnspendablePunishmentReserve = this.counterpartyUnspendablePunishmentReserve, + isChannelReady = false, + isUsable = false, + ) + val connectionText = context.getString(R.string.lightning__connection) + return ChannelUi( + name = "$connectionText ${baseIndex + 1}", + details = channelDetails, + closureReason = this.channelClosureReason.takeIf { it.isNotEmpty() } + ) } private fun ChannelDetails.mapToUiModel(): ChannelUi = ChannelUi( @@ -361,6 +427,7 @@ data class LightningConnectionsUiState( val openChannels: List = emptyList(), val pendingConnections: List = emptyList(), val failedOrders: List = emptyList(), + val closedChannels: List = emptyList(), val localBalance: ULong = 0u, val remoteBalance: ULong = 0u, ) @@ -368,6 +435,7 @@ data class LightningConnectionsUiState( data class ChannelUi( val name: String, val details: ChannelDetails, + val closureReason: String? = null, ) data class CloseConnectionUiState( diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/components/ChannelStatusView.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/components/ChannelStatusView.kt index e60b4d2e0..d77040376 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/components/ChannelStatusView.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/components/ChannelStatusView.kt @@ -32,12 +32,13 @@ import to.bitkit.ui.theme.Colors fun ChannelStatusView( channel: ChannelUi, blocktankOrder: IBtOrder?, + isClosedChannel: Boolean = false, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp) ) { - val statusInfo = getStatusInfo(channel, blocktankOrder) + val statusInfo = getStatusInfo(channel, blocktankOrder, isClosedChannel) Box( contentAlignment = Alignment.Center, @@ -64,7 +65,19 @@ fun ChannelStatusView( private fun getStatusInfo( channel: ChannelUi, blocktankOrder: IBtOrder?, + isClosedChannel: Boolean = false, ): StatusInfo { + // Check if it's a closed channel first + if (isClosedChannel) { + return StatusInfo( + iconRes = R.drawable.ic_lightning, + backgroundColor = Colors.White10, + iconColor = Colors.White64, + statusText = stringResource(R.string.lightning__order_state__closed), + statusColor = Colors.White64 + ) + } + // Use open/closed status from LDK if available when { // open @@ -88,18 +101,6 @@ private fun getStatusInfo( statusColor = Colors.Yellow, ) } - - // closed - // TODO: handle closed channels marking & detection - // else -> { - // return StatusInfo( - // iconRes = R.drawable.ic_lightning, - // backgroundColor = Colors.White10, - // iconColor = Colors.White64, - // statusText = stringResource(R.string.lightning__order_state__closed), - // statusColor = Colors.White64, - // ) - // } } blocktankOrder?.let { order -> @@ -179,24 +180,12 @@ private fun getStatusInfo( } // fallback for pending channels without order - if (!channel.details.isChannelReady) { - return StatusInfo( - iconRes = R.drawable.ic_hourglass_simple, - backgroundColor = Colors.Purple16, - iconColor = Colors.Purple, - statusText = stringResource(R.string.lightning__order_state__opening), - statusColor = Colors.Purple - ) - } - - // closed - // TODO: handle closed channels marking & detection return StatusInfo( - iconRes = R.drawable.ic_lightning, - backgroundColor = Colors.White10, - iconColor = Colors.White64, - statusText = stringResource(R.string.lightning__order_state__closed), - statusColor = Colors.White64 + iconRes = R.drawable.ic_hourglass_simple, + backgroundColor = Colors.Purple16, + iconColor = Colors.Purple, + statusText = stringResource(R.string.lightning__order_state__opening), + statusColor = Colors.Purple ) }