lnworker: fix todo, collect failed htlcs in payment
What changed, and why it matters
This change tweaks how Electrum's Lightning payment code reacts when one payment chunk (HTLC) fails. Instead of immediately trying a new route based on a single failure, it waits up to 0.5 seconds to collect any other failures that arrive almost at the same time. That gives the re-splitting logic more complete information, making multi-part payments more reliable. It is a reliability/robustness improvement, not a fix for a security vulnerability.
No security action required. Treat as a normal reliability improvement; review for payment UX regressions if desired.
Security signals we found
No input validation or trust-boundary changes
No cryptographic or signature handling changes
No secret material exposure
Behavior change is timing/aggregation of existing failure handling
No mention of security, CVE, vulnerability, or exploit in commit message
Evidence from the diff
The commit refactors LNWallet.pay_to_node() to loop after receiving an HTLC failure and collect additional resolved HTLCs within PaySession.TIMEOUT_WAIT_FOR_NEXT_RESOLVED_HTLC (0.5 s). Previously the code processed one resolved HTLC at a time and had a TODO noting ‘maybe wait a bit, more failures might come’. The new loop appends each failure to the log, updates liquidity hints / trampoline fee handling, and only exits when no more HTLCs are in flight or the short timeout expires. This improves MPP route re-splitting decisions by aggregating near-simultaneous failure information.
Changed components
electrum/lnworker.pyLNWallet.pay_to_node()PaySessionInspect captured patch +59 / −40
diff --git a/electrum/lnworker.py b/electrum/lnworker.py
index 607968d..dde0ba0 100644
--- a/electrum/lnworker.py
+++ b/electrum/lnworker.py
@@ -710,6 +710,10 @@ class LNGossip(LNWorker):
class PaySession(Logger):
+
+ # how long we wait for another htlc to resolve after receiving a failure for one sent htlc.
+ TIMEOUT_WAIT_FOR_NEXT_RESOLVED_HTLC = 0.5
+
def __init__(
self,
*,
@@ -754,6 +758,10 @@ class PaySession(Logger):
pkey = sha256(self.payment_key)
return f"{self.payment_hash[:4].hex()}-{pkey[:2].hex()}"
+ @property
+ def number_htlcs_inflight(self) -> int:
+ return self._nhtlcs_inflight
+
def maybe_raise_trampoline_fee(self, htlc_log: HtlcLog):
if htlc_log.trampoline_fee_level == self.trampoline_fee_level:
self.trampoline_fee_level += 1
@@ -1709,46 +1717,57 @@ class LNWallet(LNWorker):
# It is also triggered here to update progress for a lightning payment in the GUI
# (e.g. attempt counter)
util.trigger_callback('invoice_status', self.wallet, payment_hash.hex(), PR_INFLIGHT)
- # 3. await a queue
- htlc_log = await paysession.wait_for_one_htlc_to_resolve() # TODO maybe wait a bit, more failures might come
- log.append(htlc_log)
- if htlc_log.success:
- if self.network.path_finder:
- # TODO: report every route to liquidity hints for mpp
- # in the case of success, we report channels of the
- # route as being able to send the same amount in the future,
- # as we assume to not know the capacity
- self.network.path_finder.update_liquidity_hints(htlc_log.route, htlc_log.amount_msat)
- # remove inflight htlcs from liquidity hints
- self.network.path_finder.update_inflight_htlcs(htlc_log.route, add_htlcs=False)
- return
- # htlc failed
- # if we get a tmp channel failure, it might work to split the amount and try more routes
- # if we get a channel update, we might retry the same route and amount
- route = htlc_log.route
- sender_idx = htlc_log.sender_idx
- failure_msg = htlc_log.failure_msg
- if sender_idx is None:
- raise PaymentFailure(failure_msg.code_name())
- erring_node_id = route[sender_idx].node_id
- code, data = failure_msg.code, failure_msg.data
- self.logger.info(f"UPDATE_FAIL_HTLC. code={repr(code)}. "
- f"decoded_data={failure_msg.decode_data()}. data={data.hex()!r}")
- self.logger.info(f"error reported by {erring_node_id.hex()}")
- if code == OnionFailureCode.MPP_TIMEOUT:
- raise PaymentFailure(failure_msg.code_name())
- # errors returned by the next trampoline.
- if fwd_trampoline_onion and code in [
- OnionFailureCode.TRAMPOLINE_FEE_INSUFFICIENT,
- OnionFailureCode.TRAMPOLINE_EXPIRY_TOO_SOON]:
- raise failure_msg
- # trampoline
- if self.uses_trampoline():
- paysession.handle_failed_trampoline_htlc(
- htlc_log=htlc_log, failure_msg=failure_msg)
- else:
- self.handle_error_code_from_failed_htlc(
- route=route, sender_idx=sender_idx, failure_msg=failure_msg, amount=htlc_log.amount_msat)
+ # 3. await a queue, collect resolved htlcs
+ htlc_log = await paysession.wait_for_one_htlc_to_resolve()
+ while True:
+ log.append(htlc_log)
+ if htlc_log.success:
+ if self.network.path_finder:
+ # TODO: report every route to liquidity hints for mpp
+ # in the case of success, we report channels of the
+ # route as being able to send the same amount in the future,
+ # as we assume to not know the capacity
+ self.network.path_finder.update_liquidity_hints(htlc_log.route, htlc_log.amount_msat)
+ # remove inflight htlcs from liquidity hints
+ self.network.path_finder.update_inflight_htlcs(htlc_log.route, add_htlcs=False)
+ return
+ # htlc failed
+ # if we get a tmp channel failure, it might work to split the amount and try more routes
+ # if we get a channel update, we might retry the same route and amount
+ route = htlc_log.route
+ sender_idx = htlc_log.sender_idx
+ failure_msg = htlc_log.failure_msg
+ if sender_idx is None:
+ raise PaymentFailure(failure_msg.code_name())
+ erring_node_id = route[sender_idx].node_id
+ code, data = failure_msg.code, failure_msg.data
+ self.logger.info(f"UPDATE_FAIL_HTLC. code={repr(code)}. "
+ f"decoded_data={failure_msg.decode_data()}. data={data.hex()!r}")
+ self.logger.info(f"error reported by {erring_node_id.hex()}")
+ if code == OnionFailureCode.MPP_TIMEOUT:
+ raise PaymentFailure(failure_msg.code_name())
+ # errors returned by the next trampoline.
+ if fwd_trampoline_onion and code in [
+ OnionFailureCode.TRAMPOLINE_FEE_INSUFFICIENT,
+ OnionFailureCode.TRAMPOLINE_EXPIRY_TOO_SOON]:
+ raise failure_msg
+ # trampoline
+ if self.uses_trampoline():
+ paysession.handle_failed_trampoline_htlc(
+ htlc_log=htlc_log, failure_msg=failure_msg)
+ else:
+ self.handle_error_code_from_failed_htlc(
+ route=route, sender_idx=sender_idx, failure_msg=failure_msg, amount=htlc_log.amount_msat)
+ if paysession.number_htlcs_inflight < 1:
+ break
+ # wait a bit, more failures might come
+ try:
+ htlc_log = await util.wait_for2(
+ paysession.wait_for_one_htlc_to_resolve(),
+ timeout=paysession.TIMEOUT_WAIT_FOR_NEXT_RESOLVED_HTLC)
+ except asyncio.TimeoutError:
+ break
+
# max attempts or timeout
if (attempts is not None and len(log) >= attempts) or (attempts is None and time.time() - paysession.start_time > self.PAYMENT_TIMEOUT):
raise PaymentFailure('Giving up after %d attempts'%len(log))
Why this scored 23/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.