diff --git a/electrum/gui/qt/channel_details.py b/electrum/gui/qt/channel_details.py index d60a211cbb1f..f49a851cc02f 100644 --- a/electrum/gui/qt/channel_details.py +++ b/electrum/gui/qt/channel_details.py @@ -180,10 +180,11 @@ def get_common_form(self, chan): form.addRow(QLabel(_('Remote Node') + ':'), remote_id_e) channel_id_e = ShowQRLineEdit(chan.channel_id.hex(), self.window.config, title=_("Channel ID")) form.addRow(QLabel(_('Channel ID') + ':'), channel_id_e) - form.addRow(QLabel(_('Short Channel ID') + ':'), QLabel(str(chan.short_channel_id))) + alias = chan.get_remote_alias() + if alias: + form.addRow(QLabel(_('Alias') + ':'), QLabel('0x'+alias.hex())) form.addRow(QLabel(_('State') + ':'), SelectableLabel(chan.get_state_for_GUI())) - self.capacity = self.format_sat(chan.get_capacity()) form.addRow(QLabel(_('Capacity') + ':'), SelectableLabel(self.capacity)) if not chan.is_backup(): diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py index 1fe4991106c4..3eefe48f5dda 100644 --- a/electrum/lnchannel.py +++ b/electrum/lnchannel.py @@ -614,6 +614,18 @@ def __init__(self, state: 'StoredDict', *, name=None, lnworker=None, initial_fee self.should_request_force_close = False self.unconfirmed_closing_txid = None # not a state, only for GUI + def get_local_alias(self) -> bytes: + # deterministic, same secrecy level as wallet master pubkey + wallet_fingerprint = bytes(self.lnworker.wallet.get_fingerprint(), "utf8") + return sha256(wallet_fingerprint + self.channel_id)[0:8] + + def save_remote_alias(self, alias: bytes): + self.storage['alias'] = alias.hex() + + def get_remote_alias(self) -> Optional[bytes]: + alias = self.storage.get('alias') + return bytes.fromhex(alias) if alias else None + def has_onchain_backup(self): return self.storage.get('has_onchain_backup', False) diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 273626295476..d1151441bb7c 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -388,7 +388,7 @@ def maybe_save_remote_update(self, payload): if not self.channels: return for chan in self.channels.values(): - if chan.short_channel_id == payload['short_channel_id']: + if payload['short_channel_id'] in [chan.short_channel_id, chan.get_local_alias()]: chan.set_remote_update(payload) self.logger.info(f"saved remote channel_update gossip msg for chan {chan.get_id_for_log()}") break @@ -712,6 +712,8 @@ async def channel_establishment_flow( open_channel_tlvs = {} assert self.their_features.supports(LnFeatures.OPTION_STATIC_REMOTEKEY_OPT) our_channel_type = ChannelType(ChannelType.OPTION_STATIC_REMOTEKEY) + # We do not set the option_scid_alias bit in channel_type because LND rejects it. + # Eclair accepts channel_type with that bit, but does not require it. # if option_channel_type is negotiated: MUST set channel_type if self.is_channel_type(): @@ -1287,16 +1289,30 @@ def send_channel_ready(self, chan: Channel): per_commitment_secret_index = RevocationStore.START_INDEX - 1 second_per_commitment_point = secret_to_pubkey(int.from_bytes( get_per_commitment_secret_from_seed(chan.config[LOCAL].per_commitment_secret_seed, per_commitment_secret_index), 'big')) + + channel_ready_tlvs = {} + if self.their_features.supports(LnFeatures.OPTION_SCID_ALIAS_OPT): + # LND requires that we send an alias if the option has been negotiated in INIT. + # otherwise, the channel will not be marked as active. + # This does not apply if the channel was previously marked active without an alias. + channel_ready_tlvs['short_channel_id'] = {'alias':chan.get_local_alias()} + # note: if 'channel_ready' was not yet received, we might send it multiple times self.send_message( "channel_ready", channel_id=channel_id, - second_per_commitment_point=second_per_commitment_point) + second_per_commitment_point=second_per_commitment_point, + channel_ready_tlvs=channel_ready_tlvs) if chan.is_funded() and chan.config[LOCAL].funding_locked_received: self.mark_open(chan) def on_channel_ready(self, chan: Channel, payload): self.logger.info(f"on_channel_ready. channel: {bh2u(chan.channel_id)}") + # save remote alias for use in invoices + scid_alias = payload.get('channel_ready_tlvs', {}).get('short_channel_id', {}).get('alias') + if scid_alias: + chan.save_remote_alias(scid_alias) + if not chan.config[LOCAL].funding_locked_received: their_next_point = payload["second_per_commitment_point"] chan.config[REMOTE].next_per_commitment_point = their_next_point diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 9a5012f8d730..2b1f3a7ce949 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1116,6 +1116,12 @@ class LnFeatures(IntFlag): _ln_feature_contexts[OPTION_CHANNEL_TYPE_REQ] = (LNFC.INIT | LNFC.NODE_ANN) _ln_feature_contexts[OPTION_CHANNEL_TYPE_OPT] = (LNFC.INIT | LNFC.NODE_ANN) + OPTION_SCID_ALIAS_REQ = 1 << 46 + OPTION_SCID_ALIAS_OPT = 1 << 47 + + _ln_feature_contexts[OPTION_SCID_ALIAS_REQ] = (LNFC.INIT | LNFC.NODE_ANN) + _ln_feature_contexts[OPTION_SCID_ALIAS_OPT] = (LNFC.INIT | LNFC.NODE_ANN) + def validate_transitive_dependencies(self) -> bool: # for all even bit set, set corresponding odd bit: features = self # copy @@ -1198,6 +1204,8 @@ class ChannelType(IntFlag): OPTION_STATIC_REMOTEKEY = 1 << 12 OPTION_ANCHOR_OUTPUTS = 1 << 20 OPTION_ANCHORS_ZERO_FEE_HTLC_TX = 1 << 22 + OPTION_SCID_ALIAS = 1 << 46 + OPTION_ZEROCONF = 1 << 50 def discard_unknown_and_check(self): """Discards unknown flags and checks flag combination.""" @@ -1215,13 +1223,12 @@ def discard_unknown_and_check(self): return final_channel_type def check_combinations(self): - if self == ChannelType.OPTION_STATIC_REMOTEKEY: - pass - elif self == ChannelType.OPTION_ANCHOR_OUTPUTS | ChannelType.OPTION_STATIC_REMOTEKEY: - pass - elif self == ChannelType.OPTION_ANCHORS_ZERO_FEE_HTLC_TX | ChannelType.OPTION_STATIC_REMOTEKEY: - pass - else: + basic_type = self & ~(ChannelType.OPTION_SCID_ALIAS | ChannelType.OPTION_ZEROCONF) + if basic_type not in [ + ChannelType.OPTION_STATIC_REMOTEKEY, + ChannelType.OPTION_ANCHOR_OUTPUTS | ChannelType.OPTION_STATIC_REMOTEKEY, + ChannelType.OPTION_ANCHORS_ZERO_FEE_HTLC_TX | ChannelType.OPTION_STATIC_REMOTEKEY + ]: raise ValueError("Channel type is not a valid flag combination.") def complies_with_features(self, features: LnFeatures) -> bool: @@ -1240,7 +1247,10 @@ def to_bytes_minimal(self): @property def name_minimal(self): - return self.name.replace('OPTION_', '') + if self.name: + return self.name.replace('OPTION_', '') + else: + return str(self) del LNFC # name is ambiguous without context @@ -1259,6 +1269,7 @@ def name_minimal(self): | LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ELECTRUM | LnFeatures.OPTION_TRAMPOLINE_ROUTING_REQ_ELECTRUM | LnFeatures.OPTION_SHUTDOWN_ANYSEGWIT_OPT | LnFeatures.OPTION_SHUTDOWN_ANYSEGWIT_REQ | LnFeatures.OPTION_CHANNEL_TYPE_OPT | LnFeatures.OPTION_CHANNEL_TYPE_REQ + | LnFeatures.OPTION_SCID_ALIAS_OPT | LnFeatures.OPTION_SCID_ALIAS_REQ ) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 9e01c9ebfe53..2c869f20d4a0 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -187,6 +187,7 @@ class ErrorAddingPeer(Exception): pass | LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ELECTRUM\ | LnFeatures.OPTION_SHUTDOWN_ANYSEGWIT_OPT\ | LnFeatures.OPTION_CHANNEL_TYPE_OPT\ + | LnFeatures.OPTION_SCID_ALIAS_OPT\ LNGOSSIP_FEATURES = BASE_FEATURES\ | LnFeatures.GOSSIP_QUERIES_OPT\ @@ -1361,6 +1362,7 @@ async def pay_to_route( # send a single htlc short_channel_id = route[0].short_channel_id chan = self.get_channel_by_short_id(short_channel_id) + assert chan, ShortChannelID(short_channel_id) peer = self._peers.get(route[0].node_id) if not peer: raise PaymentFailure('Dropped peer') @@ -1708,6 +1710,7 @@ def create_route_for_payment( my_sending_channels: List[Channel], full_path: Optional[LNPaymentPath]) -> LNPaymentRoute: + my_sending_aliases = set(chan.get_local_alias() for chan in my_sending_channels) my_sending_channels = {chan.short_channel_id: chan for chan in my_sending_channels if chan.short_channel_id is not None} # Collect all private edges from route hints. @@ -1719,6 +1722,10 @@ def create_route_for_payment( private_path_nodes = [edge[0] for edge in private_path][1:] + [invoice_pubkey] private_path_rest = [edge[1:] for edge in private_path] start_node = private_path[0][0] + # remove aliases from direct routes + if len(private_path) == 1 and private_path[0][1] in my_sending_aliases: + self.logger.info(f'create_route: skipping alias {ShortChannelID(private_path[0][1])}') + continue for end_node, edge_rest in zip(private_path_nodes, private_path_rest): short_channel_id, fee_base_msat, fee_proportional_millionths, cltv_expiry_delta = edge_rest short_channel_id = ShortChannelID(short_channel_id) @@ -2024,9 +2031,9 @@ def calc_routing_hints_for_invoice(self, amount_msat: Optional[int], channels=No scid_to_my_channels = {chan.short_channel_id: chan for chan in channels if chan.short_channel_id is not None} for chan in channels: - chan_id = chan.short_channel_id - assert isinstance(chan_id, bytes), chan_id - channel_info = get_mychannel_info(chan_id, scid_to_my_channels) + alias_or_scid = chan.get_remote_alias() or chan.short_channel_id + assert isinstance(alias_or_scid, bytes), alias_or_scid + channel_info = get_mychannel_info(chan.short_channel_id, scid_to_my_channels) # note: as a fallback, if we don't have a channel update for the # incoming direction of our private channel, we fill the invoice with garbage. # the sender should still be able to pay us, but will incur an extra round trip @@ -2044,11 +2051,11 @@ def calc_routing_hints_for_invoice(self, amount_msat: Optional[int], channels=No missing_info = False if missing_info: self.logger.info( - f"Warning. Missing channel update for our channel {chan_id}; " + f"Warning. Missing channel update for our channel {chan.short_channel_id}; " f"filling invoice with incorrect data.") routing_hints.append(('r', [( chan.node_id, - chan_id, + alias_or_scid, fee_base_msat, fee_proportional_millionths, cltv_expiry_delta)])) diff --git a/electrum/tests/test_lnpeer.py b/electrum/tests/test_lnpeer.py index df2c930c472b..1aa2510bb919 100644 --- a/electrum/tests/test_lnpeer.py +++ b/electrum/tests/test_lnpeer.py @@ -123,6 +123,9 @@ def is_lightning_backup(self): def is_mine(self, addr): return True + def get_fingerprint(self): + return '' + class MockLNWallet(Logger, EventListener, NetworkRetryManager[LNPeerAddr]): MPP_EXPIRY = 2 # HTLC timestamps are cast to int, so this cannot be 1 @@ -152,6 +155,7 @@ def __init__(self, *, local_keypair: Keypair, chans: Iterable['Channel'], tx_que self.features |= LnFeatures.PAYMENT_SECRET_OPT self.features |= LnFeatures.OPTION_TRAMPOLINE_ROUTING_OPT_ELECTRUM self.features |= LnFeatures.OPTION_CHANNEL_TYPE_OPT + self.features |= LnFeatures.OPTION_SCID_ALIAS_OPT self.pending_payments = defaultdict(asyncio.Future) for chan in chans: chan.lnworker = self