lnworker: use channel_id instead of scid in ReceivedMPPHtlc
What changed, and why it matters
This commit changes how Electrum's Lightning wallet internally tracks incoming multi-part payments. Instead of using the short channel ID (which can be missing or ambiguous), it now uses the permanent channel ID. The change includes a database upgrade to convert old stored data. The commit message frames this as a robustness improvement, not a security fix, but the scenario it addresses—multiple channels lacking short IDs—could theoretically lead to mis-routing or incorrect handling of pending payments.
Treat as a hardening/robustness change rather than an active vulnerability. Reviewers should verify that the database migration correctly handles wallets where a stored short_channel_id no longer maps to a channel, and that all code paths consuming ReceivedMPPHtlc now consistently use channel_id. Monitor for follow-up commits or disclosures that clarify whether this addresses a reproducible security issue.
Security signals we found
Changes identifier used for Lightning HTLC routing/settlement from short_channel_id to channel_id
Adds database migration converting stored short_channel_id values to channel_id
Commit message notes short_channel_id can be None and ambiguous across multiple channels
Touches MPP resolution, HTLC forwarding, and cleanup logic
Evidence from the diff
The patch modifies ReceivedMPPHtlc to store channel_id (bytes) instead of scid (ShortChannelID). Call sites in lnpeer.py, lnworker.py, and the wallet database upgrade (_convert_version_65) are updated accordingly. The stated motivation is that short_channel_id can be None for multiple channels simultaneously, making channel_id a more reliable identifier. The change touches HTLC settlement, MPP set cleanup, forwarding, and logging paths. There is no explicit security claim in the commit or supplied references, and no CVE or attribution is present.
Changed components
electrum/lnpeer.pyelectrum/lnutil.pyelectrum/lnworker.pyelectrum/wallet_db.pyLightning MPP (multi-part payment) handlingWallet database upgrade pathInspect captured patch +46 / −18
diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py
index eaaddac..ec13629 100644
--- a/electrum/lnpeer.py
+++ b/electrum/lnpeer.py
@@ -2150,7 +2150,7 @@ class Peer(Logger, EventListener):
Does additional checks on the incoming htlc and return the payment key if the tests pass,
otherwise raises OnionRoutingError which will get the htlc failed.
"""
- _log_fail_reason = self._log_htlc_fail_reason_cb(chan.short_channel_id, htlc, processed_onion.hop_data.payload)
+ _log_fail_reason = self._log_htlc_fail_reason_cb(chan.channel_id, htlc, processed_onion.hop_data.payload)
# Check that our blockchain tip is sufficiently recent so that we have an approx idea of the height.
# We should not release the preimage for an HTLC that its sender could already time out as
@@ -2269,7 +2269,7 @@ class Peer(Logger, EventListener):
self.lnworker.dont_expire_htlcs.pop(payment_hash.hex(), None) # htlcs wont get expired anymore
for mpp_htlc in list(htlc_set.htlcs):
htlc_id = mpp_htlc.htlc.htlc_id
- chan = self.lnworker.get_channel_by_short_id(mpp_htlc.scid)
+ chan = self.lnworker.channels[mpp_htlc.channel_id]
if chan.channel_id not in self.channels:
# this htlc belongs to another peer and has to be settled in their htlc_switch
continue
@@ -2311,7 +2311,7 @@ class Peer(Logger, EventListener):
self.lnworker.dont_expire_htlcs.pop(payment_hash.hex(), None)
self.lnworker.dont_settle_htlcs.pop(payment_hash.hex(), None) # already failed
for mpp_htlc in list(htlc_set.htlcs):
- chan = self.lnworker.get_channel_by_short_id(mpp_htlc.scid)
+ chan = self.lnworker.channels[mpp_htlc.channel_id]
htlc_id = mpp_htlc.htlc.htlc_id
if chan.channel_id not in self.channels:
# this htlc belongs to another peer and has to be settled in their htlc_switch
@@ -2854,7 +2854,7 @@ class Peer(Logger, EventListener):
)
self.lnworker.update_or_create_mpp_with_received_htlc(
payment_key=payment_key,
- scid=chan.short_channel_id,
+ channel_id=chan.channel_id,
htlc=htlc,
unprocessed_onion_packet=onion_packet_hex, # outer onion if trampoline
)
@@ -2938,11 +2938,12 @@ class Peer(Logger, EventListener):
def _log_htlc_fail_reason_cb(
self,
- scid: ShortChannelID,
+ channel_id: bytes,
htlc: UpdateAddHtlc,
onion_payload: dict
) -> Callable[[str], None]:
def _log_fail_reason(reason: str) -> None:
+ scid = self.lnworker.channels[channel_id].short_channel_id
self.logger.info(f"will FAIL HTLC: {str(scid)=}. {reason=}. {str(htlc)=}. {onion_payload=}")
return _log_fail_reason
@@ -2960,7 +2961,7 @@ class Peer(Logger, EventListener):
onion_payload = {}
self._log_htlc_fail_reason_cb(
- mpp_htlc.scid,
+ mpp_htlc.channel_id,
mpp_htlc.htlc,
onion_payload,
)(f"mpp set {id(mpp_set)} failed: {reason}")
@@ -3074,7 +3075,7 @@ class Peer(Logger, EventListener):
if mpp_set.resolution == RecvMPPResolution.WAITING:
# calculate the sum of just in time channel opening fees
- htlc_channels = [self.lnworker.get_channel_by_short_id(scid) for scid in set(h.scid for h in mpp_set.htlcs)]
+ htlc_channels = [self.lnworker.channels[channel_id] for channel_id in set(h.channel_id for h in mpp_set.htlcs)]
jit_opening_fees_msat = sum((c.jit_opening_fee or 0) for c in htlc_channels)
# check if set is first stage multi-trampoline payment to us
diff --git a/electrum/lnutil.py b/electrum/lnutil.py
index 70256a7..f670b4b 100644
--- a/electrum/lnutil.py
+++ b/electrum/lnutil.py
@@ -1965,18 +1965,18 @@ del r
class ReceivedMPPHtlc(NamedTuple):
- scid: ShortChannelID
+ channel_id: bytes
htlc: UpdateAddHtlc
unprocessed_onion: str
def __repr__(self):
- return f"{self.scid}, {self.htlc=}, {self.unprocessed_onion[:15]=}..."
+ return f"chan_id={self.channel_id.hex()}, {self.htlc=}, {self.unprocessed_onion[:15]=}..."
@staticmethod
- def from_tuple(scid, htlc, unprocessed_onion) -> 'ReceivedMPPHtlc':
- assert is_hex_str(unprocessed_onion) and is_hex_str(scid)
+ def from_tuple(channel_id, htlc, unprocessed_onion) -> 'ReceivedMPPHtlc':
+ assert is_hex_str(unprocessed_onion) and is_hex_str(channel_id)
return ReceivedMPPHtlc(
- scid=ShortChannelID(bytes.fromhex(scid)),
+ channel_id=bytes.fromhex(channel_id),
htlc=UpdateAddHtlc.from_tuple(*htlc),
unprocessed_onion=unprocessed_onion,
)
diff --git a/electrum/lnworker.py b/electrum/lnworker.py
index f28c99a..1db9d0d 100644
--- a/electrum/lnworker.py
+++ b/electrum/lnworker.py
@@ -2583,7 +2583,7 @@ class LNWallet(LNWorker):
self,
*,
payment_key: str,
- scid: ShortChannelID,
+ channel_id: bytes,
htlc: UpdateAddHtlc,
unprocessed_onion_packet: str,
):
@@ -2610,7 +2610,7 @@ class LNWallet(LNWorker):
if mpp_status.resolution > RecvMPPResolution.WAITING:
# we are getting a htlc for a set that is not in WAITING state, it cannot be safely added
- self.logger.info(f"htlc set cannot accept htlc, failing htlc: {scid=} {htlc.htlc_id=}")
+ self.logger.info(f"htlc set cannot accept htlc, failing htlc: {channel_id=} {htlc.htlc_id=}")
if mpp_status == RecvMPPResolution.EXPIRED:
raise OnionRoutingFailure(code=OnionFailureCode.MPP_TIMEOUT, data=b'')
raise OnionRoutingFailure(
@@ -2619,7 +2619,7 @@ class LNWallet(LNWorker):
)
new_htlc = ReceivedMPPHtlc(
- scid=scid,
+ channel_id=channel_id,
htlc=htlc,
unprocessed_onion=unprocessed_onion_packet,
)
@@ -2707,7 +2707,7 @@ class LNWallet(LNWorker):
# only cleanup when channel is REDEEMED as mpp set is still required for lnsweep
assert chan._state == ChannelState.REDEEMED
for payment_key_hex, mpp_status in list(self.received_mpp_htlcs.items()):
- htlcs_to_remove = [htlc for htlc in mpp_status.htlcs if htlc.scid == chan.short_channel_id]
+ htlcs_to_remove = [htlc for htlc in mpp_status.htlcs if htlc.channel_id == chan.channel_id]
for stale_mpp_htlc in htlcs_to_remove:
assert mpp_status.resolution != RecvMPPResolution.WAITING
self.logger.info(f'maybe_cleanup_mpp: removing htlc of MPP {payment_key_hex}')
@@ -3547,7 +3547,7 @@ class LNWallet(LNWorker):
assert not any_outer_onion.are_we_final
assert len(processed_htlc_set) == 1, processed_htlc_set
forward_htlc = any_mpp_htlc.htlc
- incoming_chan = self.get_channel_by_short_id(any_mpp_htlc.scid)
+ incoming_chan = self.channels[any_mpp_htlc.channel_id]
next_htlc = await self._maybe_forward_htlc(
incoming_chan=incoming_chan,
htlc=forward_htlc,
diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py
index 325eb36..068499c 100644
--- a/electrum/wallet_db.py
+++ b/electrum/wallet_db.py
@@ -69,7 +69,7 @@ class WalletUnfinished(WalletFileException):
# seed_version is now used for the version of the wallet file
OLD_SEED_VERSION = 4 # electrum versions < 2.0
NEW_SEED_VERSION = 11 # electrum versions >= 2.0
-FINAL_SEED_VERSION = 64 # electrum >= 2.7 will set this to prevent
+FINAL_SEED_VERSION = 65 # electrum >= 2.7 will set this to prevent
# old versions from overwriting new format
@@ -236,6 +236,7 @@ class WalletDBUpgrader(Logger):
self._convert_version_62()
self._convert_version_63()
self._convert_version_64()
+ self._convert_version_65()
self.put('seed_version', FINAL_SEED_VERSION) # just to be sure
def _convert_wallet_type(self):
@@ -1288,6 +1289,32 @@ class WalletDBUpgrader(Logger):
self.data['lightning_payments'] = new_payment_infos
self.data['seed_version'] = 64
+ def _convert_version_65(self):
+ """Store channel_id instead of short_channel_id in ReceivedMPPHtlc"""
+ if not self._is_upgrade_method_needed(64, 64):
+ return
+
+ channels = self.data.get('channels', {})
+ def scid_to_channel_id(scid):
+ for channel_id, channel_data in channels.items():
+ if scid == channel_data.get('short_channel_id'):
+ return channel_id
+ raise KeyError(f"missing {scid=} in channels")
+
+ mpp_sets = self.data.get('received_mpp_htlcs', {})
+ new_mpp_sets = {}
+ for payment_key, mpp_set in mpp_sets.items():
+ resolution, htlc_list, parent_set_key = mpp_set
+ new_htlc_list = []
+ for htlc_data_tuple in htlc_list:
+ scid, update_add_htlc, onion = htlc_data_tuple
+ channel_id = scid_to_channel_id(scid)
+ new_htlc_list.append((channel_id, update_add_htlc, onion))
+ new_mpp_sets[payment_key] = (resolution, new_htlc_list, parent_set_key)
+
+ self.data['received_mpp_htlcs'] = new_mpp_sets
+ self.data['seed_version'] = 65
+
def _convert_imported(self):
if not self._is_upgrade_method_needed(0, 13):
return
Why this scored 32/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.