What changed, and why it matters
This commit is a straightforward internal code cleanup in Electrum's Lightning payment logic. It splits a large method into a smaller helper method and changes how a successful payment exits the loop, without altering what the program actually does. There is no indication this fixes a security bug or introduces a vulnerability.
No security action required; treat as normal maintenance refactor.
Security signals we found
No security-relevant behavior change observed
Pure refactor: code movement and control-flow equivalent transformation
New PaymentSuccess exception used only for local control flow
Evidence from the diff
The change refactors LNWallet.pay_to_node() in electrum/lnworker.py by extracting the per-HTLC resolution handling into a new private method _process_htlc_log(). The original inline success path returned directly from the loop; the new helper raises a new PaymentSuccess exception to unwind out of pay_to_node(), which catches it in a new except PaymentSuccess: pass block. Failure handling logic is moved verbatim. A test mock is updated to expose the new method. No cryptographic, network, or permission logic is changed.
Changed components
electrum/lnworker.pyelectrum/lnutil.pytests/test_lnpeer.pyInspect captured patch +61 / −37
diff --git a/electrum/lnutil.py b/electrum/lnutil.py
index c21d927..76a6c87 100644
--- a/electrum/lnutil.py
+++ b/electrum/lnutil.py
@@ -465,6 +465,7 @@ class InvalidGossipMsg(Exception):
class PaymentFailure(UserFacingException): pass
+class PaymentSuccess(Exception): pass
class NoPathFound(PaymentFailure):
diff --git a/electrum/lnworker.py b/electrum/lnworker.py
index dde0ba0..80ccb2a 100644
--- a/electrum/lnworker.py
+++ b/electrum/lnworker.py
@@ -70,6 +70,7 @@ from .lnutil import (
OnchainChannelBackupStorage, ln_compare_features, IncompatibleLightningFeatures, PaymentFeeBudget,
NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, GossipForwardingMessage, MIN_FUNDING_SAT,
MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE, ReceivedMPPStatus, RecvMPPResolution,
+ PaymentSuccess,
)
from .lnonion import (
decode_onion_error, OnionFailureCode, OnionRoutingFailure, OnionPacket,
@@ -1648,6 +1649,10 @@ class LNWallet(LNWorker):
channels: Optional[Sequence[Channel]] = None,
fw_payment_key: str = None, # for forwarding
) -> None:
+ """
+ Can raise PaymentFailure, ChannelDBNotLoaded,
+ or OnionRoutingFailure (if forwarding trampoline).
+ """
assert budget
assert budget.fee_msat >= 0, budget
@@ -1721,43 +1726,8 @@ class LNWallet(LNWorker):
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)
+ await self._process_htlc_log(
+ paysession=paysession, htlc_log=htlc_log, is_forwarding_trampoline=bool(fwd_trampoline_onion))
if paysession.number_htlcs_inflight < 1:
break
# wait a bit, more failures might come
@@ -1771,12 +1741,64 @@ class LNWallet(LNWorker):
# 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))
+ except PaymentSuccess:
+ pass
finally:
paysession.is_active = False
if paysession.can_be_deleted():
self._paysessions.pop(payment_key)
paysession.logger.info(f"pay_to_node ending session for RHASH={payment_hash.hex()}")
+ async def _process_htlc_log(
+ self,
+ *,
+ paysession: PaySession,
+ htlc_log: HtlcLog,
+ is_forwarding_trampoline: bool,
+ ) -> None:
+ """Handle a single just-resolved HTLC, as part of a payment-session.
+
+ Can raise PaymentFailure, PaymentSuccess,
+ or OnionRoutingFailure (if forwarding trampoline).
+ """
+ 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)
+ raise PaymentSuccess()
+ # 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 is_forwarding_trampoline 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)
+
async def pay_to_route(
self, *,
paysession: PaySession,
diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py
index f7d8402..6859888 100644
--- a/tests/test_lnpeer.py
+++ b/tests/test_lnpeer.py
@@ -351,6 +351,7 @@ class MockLNWallet(Logger, EventListener, NetworkRetryManager[LNPeerAddr]):
maybe_forward_htlc = LNWallet.maybe_forward_htlc
maybe_forward_trampoline = LNWallet.maybe_forward_trampoline
_maybe_refuse_to_forward_htlc_that_corresponds_to_payreq_we_created = LNWallet._maybe_refuse_to_forward_htlc_that_corresponds_to_payreq_we_created
+ _process_htlc_log = LNWallet._process_htlc_log
class MockTransport:
Why this scored 13/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.