disable zeroconf as client if forwarding is enabled.
What changed, and why it matters
This commit changes how Electrum's Lightning wallet advertises and accepts 'zeroconf' channels when payment forwarding is enabled. Zeroconf channels are opened instantly without waiting for on-chain confirmations, which is useful for receiving payments quickly but risky if the wallet also forwards payments for others. The patch prevents forwarding wallets from acting as zeroconf clients and from accepting zeroconf channels, likely to stop a situation where a forwarding node could be tricked into losing money on an unconfirmed channel.
Treat this as a security-hardening fix and include it in the next release. Users running experimental Lightning forwarding should upgrade. Review whether the original behavior could have allowed a forwarding node to accept or open unconfirmed zeroconf channels in a way that enabled channel-spend or routing-loss attacks.
Security signals we found
Prevents forwarding nodes from accepting zeroconf channels, reducing exposure to unconfirmed-channel attacks
Rejects JIT zeroconf channel opens during trampoline forwarding
Tightens feature-bit advertisement so clients only signal zeroconf support to their trusted provider unless forwarding
Changes behavior around experimental Lightning forwarding features
Evidence from the diff
The patch modifies lnpeer.py, lnworker.py, and wallet.py. In lnpeer.py, the feature-bit logic now clears both OPTION_ZEROCONF_OPT and OPTION_ZEROCONF_REQ when a trusted zeroconf node is configured and forwarding is not enabled. It also rejects zeroconf channel open attempts if the peer is not the trusted node or if forwarding is enabled. In the trampoline-forwarding path, it rejects HTLCs that would trigger a just-in-time (JIT) zeroconf channel opening. lnworker.py’s can_get_zeroconf_channel() now returns False if either forwarding experiment is enabled. wallet.py replaces manual zeroconf peer lookup with a call to that helper. The commit message and comments describe this as a safety measure, not a routine feature change.
Changed components
electrum/lnpeer.pyelectrum/lnworker.pyelectrum/wallet.pyLightning zeroconf channel handlingLightning payment forwarding (experimental)Inspect captured patch +18 / −13
### electrum/lnpeer.py
@@ -106,11 +106,13 @@ def __init__(
self.pubkey = pubkey # remote pubkey
self.privkey = self.transport.privkey # local privkey
self.features = self.lnworker.features # type: LnFeatures
- if lnworker == lnworker.network.lngossip or \
- self.config.ZEROCONF_TRUSTED_NODE and pubkey != lnworker.trusted_zeroconf_node_id:
- # don't signal zeroconf support if we are client (a trusted node is configured),
- # and Peer is not our trusted node
- self.features &= ~LnFeatures.OPTION_ZEROCONF_OPT
+ forwarding = self.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS or self.config.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS
+ if lnworker == lnworker.network.lngossip \
+ or self.config.ZEROCONF_TRUSTED_NODE \
+ and pubkey != lnworker.trusted_zeroconf_node_id \
+ and not forwarding:
+ # clients signal to their trusted provider only, forwarding wallets also need to signal to peers they might fund
+ self.features &= ~(LnFeatures.OPTION_ZEROCONF_OPT | LnFeatures.OPTION_ZEROCONF_REQ)
self.their_features = LnFeatures(0) # type: LnFeatures
self.node_ids = [self.pubkey, privkey_to_pubkey(self.privkey)]
assert self.node_ids[0] != self.node_ids[1]
@@ -1301,8 +1303,11 @@ async def on_open_channel(self, payload):
raise Exception("refusing to open new static_remotekey channel")
is_zeroconf = bool(channel_type & ChannelType.OPTION_ZEROCONF)
- if is_zeroconf and not self.config.ZEROCONF_TRUSTED_NODE.startswith(self.pubkey.hex()):
- raise Exception(f"not accepting zeroconf from node {self.pubkey}")
+ if is_zeroconf:
+ if self.pubkey != self.lnworker.trusted_zeroconf_node_id:
+ raise Exception(f"not accepting zeroconf from node {self.pubkey}")
+ if self.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS or self.config.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS:
+ raise Exception(f"not accepting zeroconf as a forwarding node")
if self.lnworker.has_recoverable_channels() and not is_zeroconf:
# FIXME: we might want to keep the connection open
@@ -3202,6 +3207,9 @@ def _check_unfulfilled_htlc_set(
total_msat = total_msat_outer_onion
elif not any_trampoline_onion.are_we_final:
# trampoline forwarding
+ if jit_opening_fees_msat != 0:
+ _log_fail_reason("not accepting zeroconf channels if forwarding is enabled")
+ return OnionFailureCode.TEMPORARY_NODE_FAILURE, None, None
total_msat = total_msat_outer_onion
else:
# 2nd stage trampoline
### electrum/lnworker.py
@@ -3532,6 +3532,8 @@ def receive_requires_jit_channel(self, amount_msat: Optional[int]) -> bool:
def can_get_zeroconf_channel(self) -> bool:
if not self.config.OPEN_ZEROCONF_CHANNELS:
return False
+ if self.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS or self.config.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS:
+ return False
node_id = self.trusted_zeroconf_node_id
if not node_id:
return False
### electrum/wallet.py
@@ -3607,12 +3607,7 @@ def get_help_texts_for_receive_request(self, req: Request) -> ReceiveRequestHelp
lightning_online = self.lnworker and self.lnworker.lnpeermgr.num_peers() > 0
num_sats_can_receive = self.lnworker.num_sats_can_receive() if self.lnworker else 0
can_receive_lightning = self.lnworker and num_sats_can_receive > 0 and amount_sat <= num_sats_can_receive
- try:
- zeroconf_nodeid = extract_nodeid(self.config.ZEROCONF_TRUSTED_NODE)[0]
- except Exception:
- zeroconf_nodeid = None
- can_get_zeroconf_channel = (self.lnworker and self.config.OPEN_ZEROCONF_CHANNELS
- and self.lnworker.lnpeermgr.get_peer_by_pubkey(zeroconf_nodeid) is not None)
+ can_get_zeroconf_channel = self.lnworker and self.lnworker.can_get_zeroconf_channel()
status = self.get_invoice_status(req)
if status == PR_EXPIRED:Why this scored 59/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.