lnwallet: don't blacklist htlc failures without channel update
What changed, and why it matters
This change fixes how Electrum's Lightning wallet reacts when a payment route fails but the failing node no longer provides a channel update message, as now allowed by the Lightning protocol spec. Previously, Electrum would permanently blacklist the channel, which could make payments fail unnecessarily and degrade routing reliability. Now it records a liquidity hint and retries with a smaller amount instead, but only for liquidity-related failures. For other failure types without an update, it still blacklists the channel.
Reviewers should confirm that the blacklist decision for non-liquidity UPDATE failures without a channel_update is intentional and safe, and that liquidity hints are correctly updated in all TEMPORARY_CHANNEL_FAILURE cases. Users should upgrade to a version containing this patch to maintain Lightning payment reliability.
Security signals we found
Denial-of-service via over-blacklisting: prior behavior could cause legitimate channels to be blacklisted, degrading payment success and potentially isolating the wallet from usable routes.
Spec compliance fix: aligns Electrum with updated Lightning BOLT protocol behavior.
No cryptographic bypass or funds theft signal: the change is about routing policy, not key leakage or transaction authorization.
Evidence from the diff
The patch updates LNWallet.handle_error_code_from_failed_htlc() in electrum/lnworker.py to handle the case where an UPDATE-type failure onion message has channel_update_len == 0. Per BOLTs PR 1173, channel_update is now optional in failure messages. The code now distinguishes TEMPORARY_CHANNEL_FAILURE (treated as a liquidity issue: update liquidity hints, do not blacklist) from other UPDATE-type failure codes without an update (blacklist). It also moves liquidity-hint updating outside the channel-update branch so it applies whether or not an update was present. A new unit test verifies that a zero-length channel_update with TEMPORARY_CHANNEL_FAILURE does not blacklist the edge and records the liquidity hint.
Changed components
electrum/lnworker.pyLNWallet.handle_error_code_from_failed_htlc()LNWallet._handle_chanupd_from_failed_htlc()tests/test_lnwallet.pyInspect captured patch +48 / −14
diff --git a/electrum/lnworker.py b/electrum/lnworker.py
index cb1efff..8ce68f4 100644
--- a/electrum/lnworker.py
+++ b/electrum/lnworker.py
@@ -2194,13 +2194,18 @@ class LNWallet(Logger):
raise PaymentFailure(f'payment destination reported error: {failure_msg.code_name()}') from None
# TODO: handle unknown next peer?
- # handle failure codes that include a channel update
+ # handle failure codes that may include a channel update
if code in failure_codes:
offset = failure_codes[code]
channel_update_len = int.from_bytes(data[offset:offset+2], byteorder="big")
channel_update_as_received = data[offset+2: offset+2+channel_update_len]
- payload = self._decode_channel_update_msg(channel_update_as_received)
- if payload is None:
+ if channel_update_len == 0:
+ # the channel_update became optional
+ # https://github.com/lightning/bolts/blob/93b7ee031b50acd59967a105f1326176a37628f9/04-onion-routing.md?plain=1#L1384-L1389
+ # without an update we cannot correct our local policy for the channel, so we avoid (blacklist) it,
+ # except for liquidity failures, where the liquidity hint suffices to retry
+ blacklist = code != OnionFailureCode.TEMPORARY_CHANNEL_FAILURE
+ elif (payload := self._decode_channel_update_msg(channel_update_as_received)) is None:
self.logger.info(f'could not decode channel_update for failed htlc: '
f'{channel_update_as_received.hex()}')
blacklist = True
@@ -2211,17 +2216,15 @@ class LNWallet(Logger):
# apply the channel update or get blacklisted
blacklist, handled = self._handle_chanupd_from_failed_htlc(
payload, route=route, sender_idx=sender_idx, failure_msg=failure_msg)
- # we interpret a temporary channel failure as a liquidity issue
- # in the channel and update our liquidity hints accordingly
- if code == OnionFailureCode.TEMPORARY_CHANNEL_FAILURE:
- self.network.path_finder.update_liquidity_hints(
- route,
- amount_msat,
- failing_channel=ShortChannelID(failing_channel))
- # if we can't decide on some action, we are stuck
- if not (blacklist or handled):
- raise PaymentFailure(failure_msg.code_name())
- # for errors that do not include a channel update
+ assert blacklist or handled, "some action has to be taken on a failure with channel update"
+ # we interpret a temporary channel failure as a liquidity issue
+ # in the channel and update our liquidity hints accordingly
+ if code == OnionFailureCode.TEMPORARY_CHANNEL_FAILURE:
+ self.network.path_finder.update_liquidity_hints(
+ route,
+ amount_msat,
+ failing_channel=ShortChannelID(failing_channel))
+ # for errors that never include a channel update
else:
blacklist = True
if blacklist:
@@ -2271,6 +2274,8 @@ class LNWallet(Logger):
handled = True
else:
blacklist = True
+ else:
+ raise Exception(f"unexpected chan upd UpdateStatus: {r}")
return blacklist, handled
@classmethod
diff --git a/tests/test_lnwallet.py b/tests/test_lnwallet.py
index b08bcf0..1fd7382 100644
--- a/tests/test_lnwallet.py
+++ b/tests/test_lnwallet.py
@@ -555,3 +555,32 @@ class TestLNWallet(ElectrumTestCase):
route=two_hop_route('carol'), sender_idx=0, amount_msat=amount_msat,
failure_msg=OnionRoutingFailure(code=OnionFailureCode.FEE_INSUFFICIENT, data=failure_data))
self.assertTrue(path_finder._is_edge_blacklisted(chan_cd.short_channel_id, now=now))
+
+ async def test_missing_channel_update_from_failed_htlc(self):
+ # the channel_update in UPDATE-type failure messages is optional, nodes
+ # omitting it set the channel_update len field to zero:
+ # https://github.com/lightning/bolts/blob/93b7ee031b50acd59967a105f1326176a37628f9/04-onion-routing.md?plain=1#L1384-L1389
+ # a TEMPORARY_CHANNEL_FAILURE without channel update is a liquidity issue:
+ # we record a liquidity hint and must not blacklist the channel
+ graph = lnhelpers.prepare_chans_and_peers_in_graph(self, lnhelpers._GRAPH_DEFINITIONS['square_graph'])
+ alice_w = graph.workers['alice']
+ path_finder = alice_w.network.path_finder
+ amount_msat = 100_000_000
+ route = [
+ RouteEdge(
+ start_node=graph.workers[a].node_keypair.pubkey,
+ end_node=graph.workers[b].node_keypair.pubkey,
+ short_channel_id=graph.channels[(a, b)][0].short_channel_id,
+ fee_base_msat=0, fee_proportional_millionths=0, cltv_delta=10, node_features=0,
+ ) for a, b in [('alice', 'bob'), ('bob', 'dave')]
+ ]
+ failure_data = (0).to_bytes(2, 'big') # channel_update len field set to zero
+ alice_w.handle_error_code_from_failed_htlc(
+ route=route, sender_idx=0, amount_msat=amount_msat,
+ failure_msg=OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_CHANNEL_FAILURE, data=failure_data))
+ chan_bd = graph.channels[('bob', 'dave')][0]
+ self.assertFalse(path_finder._is_edge_blacklisted(chan_bd.short_channel_id, now=int(time.time())))
+ hint_bd = path_finder.liquidity_hints.get_hint(chan_bd.short_channel_id)
+ pubkey_b = graph.workers['bob'].node_keypair.pubkey
+ pubkey_d = graph.workers['dave'].node_keypair.pubkey
+ self.assertEqual(amount_msat, hint_bd.cannot_send(pubkey_b < pubkey_d))
Why this scored 52/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.