lnpeer: deduct jit channel fees from total amount
What changed, and why it matters
This commit fixes a bug in Electrum's Lightning payment handling for 'just-in-time' (JIT) channels. When a payment arrives through a JIT channel, the channel opener charges an opening fee. Previously, the code did not properly subtract this fee when checking whether the full payment amount had been received. This could cause multi-part payments to time out or fail even though the sender had sent enough money, because the receiver's accounting compared the raw amount received against the invoice total without accounting for the fee already taken by the JIT channel. The patch renames the fee field for clarity and deducts the JIT opening fee from the total before deciding whether the payment is complete.
Treat this as a bug-fix patch with security-relevant availability impact. Users running Lightning nodes with JIT/zeroconf channels should upgrade. Review whether the fix is complete: the patch adds a TODO to validate that the fee is reasonable, which remains unimplemented and could represent a remaining economic-attack surface. Monitor for related follow-up commits.
Security signals we found
Logic error in payment amount accounting for JIT/zeroconf channel opening fees
Potential denial-of-service via premature MPP timeout of valid payments
Incomplete handling of channel_opening_fee TLV before patch
Field rename and type change to make fee handling explicit
Evidence from the diff
The patch changes lnchannel.py and lnpeer.py. It renames Channel.opening_fee to Channel.jit_opening_fee and stores the integer fee directly rather than a dict. In lnpeer.py, when handling an incoming HTLC in maybe_fulfill_htlc, the JIT opening fee is now subtracted from both total_msat and amt_to_forward. In htlc_switch, when evaluating whether a multi-part payment (MPP) set is complete, it sums the jit_opening_fee across all HTLC channels and compares amount_msat >= total_msat - jit_opening_fees_msat. Previously the fee was not deducted in the MPP completion check, so a payment whose effective amount after fees matched the invoice could still be treated as incomplete and hit MPP_TIMEOUT. The patch also adds an assert that the channel_opening_fee TLV is only accepted for zeroconf channels and logs the fee.
Changed components
electrum/lnpeer.pyelectrum/lnchannel.pyLightning JIT/zeroconf channel openingMulti-part payment (MPP) completion logicInspect captured patch +22 / −8
diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py
index 36c2dad..f211f27 100644
--- a/electrum/lnchannel.py
+++ b/electrum/lnchannel.py
@@ -765,8 +765,15 @@ class Channel(AbstractChannel):
def __repr__(self):
return "Channel(%s)"%self.get_id_for_log()
- def __init__(self, state: 'StoredDict', *, name=None, lnworker=None, initial_feerate=None, opening_fee=None):
- self.opening_fee = opening_fee
+ def __init__(
+ self,
+ state: 'StoredDict', *,
+ name=None,
+ lnworker=None,
+ initial_feerate=None,
+ jit_opening_fee: Optional[int] = None,
+ ):
+ self.jit_opening_fee = jit_opening_fee
self.name = name
self.channel_id = bfh(state["channel_id"])
self.short_channel_id = ShortChannelID.normalize(state["short_channel_id"])
diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py
index 591ce47..22aa8a4 100644
--- a/electrum/lnpeer.py
+++ b/electrum/lnpeer.py
@@ -1316,9 +1316,12 @@ class Peer(Logger, EventListener):
# store the temp id now, so that it is recognized for e.g. 'error' messages
self.temp_id_to_id[temp_chan_id] = None
self._cleanup_temp_channelids()
- channel_opening_fee = open_channel_tlvs.get('channel_opening_fee') if open_channel_tlvs else None
+ channel_opening_fee_tlv = open_channel_tlvs.get('channel_opening_fee', {})
+ channel_opening_fee = channel_opening_fee_tlv.get('channel_opening_fee')
if channel_opening_fee:
# todo check that the fee is reasonable
+ assert is_zeroconf
+ self.logger.info(f"just-in-time opening fee: {channel_opening_fee} msat")
pass
if self.use_anchors():
@@ -1433,7 +1436,7 @@ class Peer(Logger, EventListener):
chan_dict,
lnworker=self.lnworker,
initial_feerate=feerate,
- opening_fee = channel_opening_fee,
+ jit_opening_fee = channel_opening_fee,
)
chan.storage['init_timestamp'] = int(time.time())
if isinstance(self.transport, LNTransport):
@@ -2115,8 +2118,8 @@ class Peer(Logger, EventListener):
log_fail_reason(f"'total_msat' missing from onion")
raise exc_incorrect_or_unknown_pd
- if chan.opening_fee:
- channel_opening_fee = chan.opening_fee['channel_opening_fee'] # type: int
+ if chan.jit_opening_fee:
+ channel_opening_fee = chan.jit_opening_fee
total_msat -= channel_opening_fee
amt_to_forward -= channel_opening_fee
else:
@@ -2281,7 +2284,7 @@ class Peer(Logger, EventListener):
self._fulfill_htlc(chan, htlc_id, preimage)
htlc_set.htlcs.remove(mpp_htlc)
# reset just-in-time opening fee of channel
- chan.opening_fee = None
+ chan.jit_opening_fee = None
def _fulfill_htlc(self, chan: Channel, htlc_id: int, preimage: bytes):
assert chan.hm.is_htlc_irrevocably_added_yet(htlc_proposer=REMOTE, htlc_id=htlc_id)
@@ -3070,6 +3073,10 @@ class Peer(Logger, EventListener):
return OnionFailureCode.MPP_TIMEOUT, None, None
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)]
+ 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
# first stage trampoline payment:
# is a trampoline payment + we_are_final + payment key is derived from outer onion's payment secret
@@ -3095,7 +3102,7 @@ class Peer(Logger, EventListener):
self.lnworker.received_mpp_htlcs[payment_key] = mpp_set._replace(
parent_set_key=trampoline_payment_key,
)
- elif amount_msat >= total_msat:
+ elif amount_msat >= (total_msat - jit_opening_fees_msat):
# set mpp_set as completed as we have received the full total_msat
mpp_set = self.lnworker.set_mpp_resolution(
payment_key=payment_key,
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.