Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 88 additions & 1 deletion app/src/main/java/to/bitkit/services/LightningService.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -70,6 +74,8 @@ class LightningService @Inject constructor(

private lateinit var trustedPeers: List<PeerDetails>

private val channelCache = ConcurrentHashMap<String, ChannelDetails>()

suspend fun setup(
walletIndex: Int,
customServerUrl: String? = null,
Expand Down Expand Up @@ -190,6 +196,7 @@ class LightningService @Inject constructor(
}
}

refreshChannelCache()
Logger.info("Node started")
}

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 -> {
Expand All @@ -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)
}
}
}
}
Expand All @@ -781,6 +867,7 @@ class LightningService @Inject constructor(

companion object {
private const val TAG = "LightningService"
private const val CHANNEL_ID_PREVIEW_LENGTH = 10
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -113,6 +115,7 @@ fun ChannelDetailScreen(
cjitEntries = paidOrders.cjitEntries,
txDetails = txDetails,
isRefreshing = uiState.isRefreshing,
isClosedChannel = isClosedChannel,
onBack = { navController.popBackStack() },
onClose = { navController.navigateToHome() },
onRefresh = {
Expand All @@ -137,13 +140,15 @@ fun ChannelDetailScreen(
}

@OptIn(ExperimentalMaterial3Api::class)
@Suppress("CyclomaticComplexMethod")
@Composable
private fun Content(
channel: ChannelUi,
blocktankOrders: List<IBtOrder> = emptyList(),
cjitEntries: List<IcJitEntry> = emptyList(),
txDetails: TxDetails? = null,
isRefreshing: Boolean = false,
isClosedChannel: Boolean = false,
onBack: () -> Unit = {},
onClose: () -> Unit = {},
onRefresh: () -> Unit = {},
Expand Down Expand Up @@ -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()
Expand All @@ -211,6 +216,7 @@ private fun Content(
ChannelStatusView(
channel = channel,
blocktankOrder = blocktankOrder,
isClosedChannel = isClosedChannel,
)
VerticalSpacer(16.dp)
HorizontalDivider()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading