diff --git a/Bitkit/Extensions/ShortChannelId.swift b/Bitkit/Extensions/ShortChannelId.swift index d8ae5fca3..e18562662 100644 --- a/Bitkit/Extensions/ShortChannelId.swift +++ b/Bitkit/Extensions/ShortChannelId.swift @@ -11,3 +11,28 @@ extension UInt64 { return "\(blockHeight)x\(txIndex)x\(outputIndex)" } } + +/// Whether the string is a Core Lightning `block x tx x output` short channel id (e.g. `792906x599x1`): +/// exactly three non-empty, all-digit components separated by `x`. +private func isClnShortChannelId(_ value: String) -> Bool { + let parts = value.split(separator: "x", omittingEmptySubsequences: false) + return parts.count == 3 && parts.allSatisfy { !$0.isEmpty && $0.allSatisfy { $0.isASCII && $0.isNumber } } +} + +/// Resolves the short channel id to display, formatted as `block x tx x output`. 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. Nil when unavailable. +func resolveDisplayShortChannelId(channelScid: UInt64?, linkedOrderScid: String?) -> String? { + if let channelScid { + return channelScid.formattedAsShortChannelId + } + + guard let orderScid = linkedOrderScid, !orderScid.isEmpty else { return nil } + + if let numeric = UInt64(orderScid) { + return numeric.formattedAsShortChannelId + } + + return isClnShortChannelId(orderScid) ? orderScid : nil +} diff --git a/Bitkit/ViewModels/ChannelDetailsViewModel.swift b/Bitkit/ViewModels/ChannelDetailsViewModel.swift index 0ed319b82..f04c13a35 100644 --- a/Bitkit/ViewModels/ChannelDetailsViewModel.swift +++ b/Bitkit/ViewModels/ChannelDetailsViewModel.swift @@ -76,13 +76,8 @@ class ChannelDetailsViewModel: ObservableObject { /// scid (open channels) and, for closed channels (which are not stored with one), the scid from /// a confidently linked Blocktank order. Hidden otherwise rather than showing a guessed value. var displayShortChannelId: String? { - if let scid = foundChannel?.shortChannelIdValue { - return scid.formattedAsShortChannelId - } - if isLinkedOrderConfident, let scidString = linkedOrder?.channel?.shortChannelId, let scid = UInt64(scidString) { - return scid.formattedAsShortChannelId - } - return nil + let linkedOrderScid = isLinkedOrderConfident ? linkedOrder?.channel?.shortChannelId : nil + return resolveDisplayShortChannelId(channelScid: foundChannel?.shortChannelIdValue, linkedOrderScid: linkedOrderScid) } /// Find a channel by ID, checking open channels, pending channels, pending orders, then closed channels diff --git a/BitkitTests/ChannelDetailsViewModelTests.swift b/BitkitTests/ChannelDetailsViewModelTests.swift index 3e23b70ab..305a0ad4c 100644 --- a/BitkitTests/ChannelDetailsViewModelTests.swift +++ b/BitkitTests/ChannelDetailsViewModelTests.swift @@ -48,6 +48,20 @@ final class ChannelDetailsViewModelTests: XCTestCase { XCTAssertEqual(vm.displayShortChannelId, "777477x916x0") } + @MainActor + func testDisplayShortChannelIdKeepsClnFormLinkedOrderScidForClosedChannel() { + let vm = ChannelDetailsViewModel.shared + // Blocktank delivers the order scid already in `block x tx x output` form; it is kept as-is. + vm.foundChannel = makeClosedChannel(fundingTxoTxid: "fundingtxid") + var channel = IBtChannel.mock() + channel.fundingTx = FundingTx(id: "fundingtxid", vout: 0) + channel.shortChannelId = "792906x599x1" + vm.linkedOrder = IBtOrder.mock(channel: channel) + defer { resetDisplayState(vm) } + + XCTAssertEqual(vm.displayShortChannelId, "792906x599x1") + } + @MainActor func testDisplayIgnoresWeaklyLinkedOrder() { let vm = ChannelDetailsViewModel.shared diff --git a/BitkitTests/ShortChannelIdTests.swift b/BitkitTests/ShortChannelIdTests.swift index 9ec9cb785..821b7b134 100644 --- a/BitkitTests/ShortChannelIdTests.swift +++ b/BitkitTests/ShortChannelIdTests.swift @@ -21,4 +21,30 @@ final class ShortChannelIdTests: XCTestCase { let scid = (UInt64(0xFFFFFF) << 40) | (UInt64(0xFFFFFF) << 16) | UInt64(0xFFFF) XCTAssertEqual(scid.formattedAsShortChannelId, "16777215x16777215x65535") } + + func testResolveDisplayShortChannelIdDecodesOpenChannelScid() { + XCTAssertEqual(resolveDisplayShortChannelId(channelScid: 854_845_001_888_432_128, linkedOrderScid: nil), "777477x916x0") + } + + func testResolveDisplayShortChannelIdKeepsClnFormLinkedOrderScidForClosedChannel() { + // Blocktank delivers the order scid already in `block x tx x output` form. + XCTAssertEqual(resolveDisplayShortChannelId(channelScid: nil, linkedOrderScid: "792906x599x1"), "792906x599x1") + } + + func testResolveDisplayShortChannelIdDecodesNumericLinkedOrderScid() { + XCTAssertEqual(resolveDisplayShortChannelId(channelScid: nil, linkedOrderScid: "854845001888432128"), "777477x916x0") + } + + func testResolveDisplayShortChannelIdReturnsNilWhenBothUnavailable() { + XCTAssertNil(resolveDisplayShortChannelId(channelScid: nil, linkedOrderScid: nil)) + XCTAssertNil(resolveDisplayShortChannelId(channelScid: nil, linkedOrderScid: "")) + } + + func testResolveDisplayShortChannelIdReturnsNilForMalformedOrderScid() { + XCTAssertNil(resolveDisplayShortChannelId(channelScid: nil, linkedOrderScid: "not-a-scid")) + XCTAssertNil(resolveDisplayShortChannelId(channelScid: nil, linkedOrderScid: "792906x599")) + XCTAssertNil(resolveDisplayShortChannelId(channelScid: nil, linkedOrderScid: "792906xx1")) + // Non-ASCII numeric glyphs (e.g. superscripts) are not valid scid digits. + XCTAssertNil(resolveDisplayShortChannelId(channelScid: nil, linkedOrderScid: "²x³x¹")) + } }