diff --git a/app/src/main/java/to/bitkit/ext/ShortChannelId.kt b/app/src/main/java/to/bitkit/ext/ShortChannelId.kt new file mode 100644 index 0000000000..a6972b55d7 --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/ShortChannelId.kt @@ -0,0 +1,29 @@ +package to.bitkit.ext + +/** + * Decodes a BOLT short channel id into Core Lightning `block x tx x output` form + * (e.g. `777477x916x0`): block height in the high 24 bits, transaction index in the next 24, + * funding output index in the low 16. + */ +fun ULong.formattedAsShortChannelId(): String { + val blockHeight = this shr 40 + val txIndex = (this shr 16) and 0xFFFFFFu + val outputIndex = this and 0xFFFFu + return "${blockHeight}x${txIndex}x$outputIndex" +} + +private val CLN_SHORT_CHANNEL_ID = Regex("""\d+x\d+x\d+""") + +/** + * Short channel id for display. Uses the channel's own scid (open channels, a numeric BOLT scid) + * and, for closed channels which carry none, the scid from the confidently-linked Blocktank order. + * Blocktank delivers it already in `block x tx x output` form, so an `x`-formatted value is kept + * as-is and only a numeric value is decoded. Null when unavailable. + */ +internal fun resolveDisplayShortChannelId(channelScid: ULong?, linkedOrderScid: String?): String? { + channelScid?.let { return it.formattedAsShortChannelId() } + + val orderScid = linkedOrderScid?.takeIf { it.isNotBlank() } ?: return null + orderScid.toULongOrNull()?.let { return it.formattedAsShortChannelId() } + return orderScid.takeIf { CLN_SHORT_CHANNEL_ID.matches(it) } +} 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 a41243741d..ae3bea0b6e 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 @@ -59,6 +59,7 @@ import to.bitkit.env.Env import to.bitkit.ext.DatePattern import to.bitkit.ext.amountOnClose import to.bitkit.ext.createChannelDetails +import to.bitkit.ext.resolveDisplayShortChannelId import to.bitkit.ext.setClipboardText import to.bitkit.models.Toast import to.bitkit.models.msatFloorOf @@ -220,6 +221,15 @@ private fun ChannelDetailContent( val order = blocktankOrder ?: cjitEntry + val linkedOrderScid = when (order) { + is IBtOrder -> order.channel?.shortChannelId + is IcJitEntry -> order.channel?.shortChannelId + else -> null + } + val displayShortChannelId = remember(channel, linkedOrderScid) { + resolveDisplayShortChannelId(channel.details.shortChannelId, linkedOrderScid) + } + val capacity = channel.details.channelValueSats.toLong() val localBalance = channel.details.amountOnClose.toLong() val remoteBalance = msatFloorOf(channel.details.inboundCapacityMsat).toLong() @@ -429,18 +439,20 @@ private fun ChannelDetailContent( ) } - SectionRow( - name = stringResource(R.string.lightning__channel_id), - valueContent = { - CaptionB( - text = channel.details.channelId, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - textAlign = TextAlign.End, - ) - }, - onClick = { onCopyText(channel.details.channelId) } - ) + displayShortChannelId?.let { scid -> + SectionRow( + name = stringResource(R.string.lightning__channel_id), + valueContent = { + CaptionB( + text = scid, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + textAlign = TextAlign.End, + ) + }, + onClick = { onCopyText(scid) } + ) + } channel.details.fundingTxo?.let { fundingTxo -> val channelPoint = "${fundingTxo.txid}:${fundingTxo.vout}" diff --git a/app/src/test/java/to/bitkit/ext/ShortChannelIdTest.kt b/app/src/test/java/to/bitkit/ext/ShortChannelIdTest.kt new file mode 100644 index 0000000000..e01a20a9a7 --- /dev/null +++ b/app/src/test/java/to/bitkit/ext/ShortChannelIdTest.kt @@ -0,0 +1,66 @@ +package to.bitkit.ext + +import org.junit.Test +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ShortChannelIdTest : BaseUnitTest() { + + @Test + fun `formattedAsShortChannelId formats lnd scid into cln form`() { + // The two formats the issue calls out (LND uint64 and CLN block x tx x output) + // are two encodings of the same channel. + assertEquals("777477x916x0", 854_845_001_888_432_128uL.formattedAsShortChannelId()) + } + + @Test + fun `formattedAsShortChannelId formats from components`() { + val scid = (700_000uL shl 40) or (1uL shl 16) or 2uL + assertEquals("700000x1x2", scid.formattedAsShortChannelId()) + } + + @Test + fun `formattedAsShortChannelId formats zero as all zeroes`() { + assertEquals("0x0x0", 0uL.formattedAsShortChannelId()) + } + + @Test + fun `formattedAsShortChannelId keeps max components in their fields`() { + val scid = (0xFFFFFFuL shl 40) or (0xFFFFFFuL shl 16) or 0xFFFFuL + assertEquals("16777215x16777215x65535", scid.formattedAsShortChannelId()) + } + + @Test + fun `resolveDisplayShortChannelId prefers channel scid for open channel`() { + val result = resolveDisplayShortChannelId( + channelScid = 854_845_001_888_432_128uL, + linkedOrderScid = "0", + ) + assertEquals("777477x916x0", result) + } + + @Test + fun `resolveDisplayShortChannelId keeps cln-form linked order scid for closed channel`() { + // Blocktank delivers the order scid already in `block x tx x output` form. + val result = resolveDisplayShortChannelId(channelScid = null, linkedOrderScid = "792906x599x1") + assertEquals("792906x599x1", result) + } + + @Test + fun `resolveDisplayShortChannelId decodes numeric linked order scid`() { + val result = resolveDisplayShortChannelId(channelScid = null, linkedOrderScid = "854845001888432128") + assertEquals("777477x916x0", result) + } + + @Test + fun `resolveDisplayShortChannelId returns null when both unavailable`() { + assertNull(resolveDisplayShortChannelId(channelScid = null, linkedOrderScid = null)) + assertNull(resolveDisplayShortChannelId(channelScid = null, linkedOrderScid = "")) + } + + @Test + fun `resolveDisplayShortChannelId returns null for malformed order scid`() { + assertNull(resolveDisplayShortChannelId(channelScid = null, linkedOrderScid = "not-a-scid")) + } +} diff --git a/changelog.d/next/1002.fixed.md b/changelog.d/next/1002.fixed.md new file mode 100644 index 0000000000..13938d0ea9 --- /dev/null +++ b/changelog.d/next/1002.fixed.md @@ -0,0 +1 @@ +Connection Details now shows the short channel ID for Lightning connections instead of the long internal channel ID.