lnwallet: make jit fees configurable, add mining fees
What changed, and why it matters
This commit changes how Electrum calculates fees for 'just-in-time' (JIT) Lightning channels. Previously the channel size and opening fee were hardcoded; now they are configurable settings. More importantly, the code now adds the actual Bitcoin mining fee for the funding transaction on top of the service fee, so the Lightning Service Provider (LSP) does not accidentally open channels at a loss when on-chain fees are high. It also rejects payments that would be too small after deducting those fees. This is a business-logic/economic fix rather than a remote code-execution vulnerability.
Review the default values and bounds (ZEROCONF_OPENING_FEE_PPM, ZEROCONF_CHANNEL_SIZE_PERCENT, ZEROCONF_MIN_OPENING_FEE) to ensure they cannot be set to unsafe values via config or RPC. Verify that funding_tx.get_fee() is always available and correctly denominated before the fee addition. Consider whether the 1,000 msat post-fee floor is sufficient for routing reliability. No urgent patch for a remote exploit is indicated by this commit alone.
Security signals we found
Economic/fee-calculation logic change in JIT channel opening
New configurable fee and channel-size parameters with defaults and bounds
Mining fee now explicitly included in JIT opening fee to prevent loss-making channels
New lower-bound check (>= 120%) on channel size percent to preserve channel reserve buffer
Payment rejection added when remaining amount after fees is below 1,000 msat
Evidence from the diff
The patch refactors JIT/zeroconf channel opening in lnworker.py. It replaces hardcoded funding_sat = 2 * htlc_amount with a configurable ZEROCONF_CHANNEL_SIZE_PERCENT (default 200%), and replaces a fixed 10% opening fee with a configurable ZEROCONF_OPENING_FEE_PPM (default 10,000 ppm). The key functional change is that funding_tx.get_fee() is now added to opening_base_fee_msat before charging the payer, and the remaining HTLC amount is checked to be at least 1,000 msat after deduction. A new minimum channel size assertion requires size percent >= 120%. These changes reduce the risk of the LSP operating at a loss and prevent tiny uneconomic JIT channels.
Changed components
electrum/lnworker.pyelectrum/lnpeer.pyelectrum/simple_config.pytests/test_lnwallet.pyInspect captured patch +28 / −10
diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py
index 7abe8a0..5375762 100644
--- a/electrum/lnpeer.py
+++ b/electrum/lnpeer.py
@@ -1263,7 +1263,7 @@ class Peer(Logger, EventListener):
channel_opening_fee = open_channel_tlvs.get('channel_opening_fee', {}).get('channel_opening_fee')
if channel_opening_fee: # just-in-time channel opening
assert is_zeroconf
- # the opening fee consists of the fee configured by the LSP
+ # the opening fee consists of the fee configured by the LSP + mining fees of the funding tx
channel_opening_fee_sat = channel_opening_fee // 1000
if channel_opening_fee_sat > funding_sat * 0.1:
# TODO: if there will be some discovery channel where LSPs announce their fees
diff --git a/electrum/lnworker.py b/electrum/lnworker.py
index d9b8ac6..7fb38d2 100644
--- a/electrum/lnworker.py
+++ b/electrum/lnworker.py
@@ -1493,19 +1493,22 @@ class LNWallet(Logger):
# prevent settling the htlc until the channel opening was successful so we can fail it if needed
self.dont_settle_htlcs[payment_hash.hex()] = None
try:
- funding_sat = 2 * (next_amount_msat_htlc // 1000) # try to fully spend htlcs
+ assert self.config.ZEROCONF_CHANNEL_SIZE_PERCENT >= 120, "ZEROCONF_CHANNEL_SIZE_PERCENT below min of 120%"
+ assert self.config.ZEROCONF_OPENING_FEE_PPM >= 0, f"invalid {self.config.ZEROCONF_OPENING_FEE_PPM=}"
+ funding_sat = (self.config.ZEROCONF_CHANNEL_SIZE_PERCENT * (next_amount_msat_htlc // 1000)) // 100
password = self.wallet.get_unlocked_password() if self.wallet.has_password() else None
- channel_opening_fee = next_amount_msat_htlc // 100
- if channel_opening_fee // 1000 < self.config.ZEROCONF_MIN_OPENING_FEE:
- self.logger.info(f'rejecting JIT channel: payment too low')
+ channel_opening_base_fee_msat = (next_amount_msat_htlc * self.config.ZEROCONF_OPENING_FEE_PPM) // 1_000_000
+ if channel_opening_base_fee_msat // 1000 < self.config.ZEROCONF_MIN_OPENING_FEE:
+ self.logger.info(
+ f'rejecting JIT channel: {(channel_opening_base_fee_msat // 1000)=} < {self.config.ZEROCONF_MIN_OPENING_FEE=}'
+ )
raise OnionRoutingFailure(code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, data=b'payment too low')
- self.logger.info(f'channel opening fee (sats): {channel_opening_fee//1000}')
next_chan, funding_tx = await self.open_channel_with_peer(
next_peer, funding_sat,
push_sat=0,
zeroconf=True,
public=False,
- opening_fee=channel_opening_fee,
+ opening_base_fee_msat=channel_opening_base_fee_msat,
password=password,
)
async def wait_for_channel():
@@ -1513,7 +1516,11 @@ class LNWallet(Logger):
await asyncio.sleep(1)
await util.wait_for2(wait_for_channel(), LN_P2P_NETWORK_TIMEOUT)
self.logger.info(f'JIT channel is open (will forward htlc and await preimage now)')
- next_amount_msat_htlc -= channel_opening_fee
+ self.logger.info(f'channel opening fee (sats): {channel_opening_base_fee_msat//1000} + {funding_tx.get_fee()} mining fee')
+ next_amount_msat_htlc -= channel_opening_base_fee_msat + funding_tx.get_fee() * 1000
+ if next_amount_msat_htlc < 1_000:
+ self.logger.info(f'rejecting JIT channel: payment too low after deducting mining fees')
+ raise OnionRoutingFailure(code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, data=b'payment too low after deducting mining fees')
# fixme: some checks are missing
htlc = next_peer.send_htlc(
chan=next_chan,
@@ -1546,6 +1553,7 @@ class LNWallet(Logger):
data=b'failed to broadcast funding transaction',
)
except Exception as e:
+ self.logger.warning(f"failed to open just in time channel: {repr(e)}")
if next_chan:
await self._cleanup_failed_jit_channel(next_chan)
self._preimages.pop(payment_hash.hex(), None)
@@ -1581,7 +1589,7 @@ class LNWallet(Logger):
push_sat: int = 0,
public: bool = False,
zeroconf: bool = False,
- opening_fee: int = None,
+ opening_base_fee_msat: Optional[int] = None,
password=None):
if self.config.ENABLE_ANCHOR_CHANNELS:
self.wallet.unlock(password)
@@ -1593,6 +1601,9 @@ class LNWallet(Logger):
funding_sat=funding_sat,
node_id=node_id,
fee_policy=fee_policy)
+ if opening_base_fee_msat:
+ # add funding tx fee on top of the opening fee to avoid opening channels at a loss
+ opening_base_fee_msat += funding_tx.get_fee() * 1000
chan, funding_tx = await self._open_channel_coroutine(
peer=peer,
funding_tx=funding_tx,
@@ -1600,7 +1611,7 @@ class LNWallet(Logger):
push_sat=push_sat,
public=public,
zeroconf=zeroconf,
- opening_fee=opening_fee,
+ opening_fee=opening_base_fee_msat,
password=password)
return chan, funding_tx
diff --git a/electrum/simple_config.py b/electrum/simple_config.py
index 4918eb2..56bfca4 100644
--- a/electrum/simple_config.py
+++ b/electrum/simple_config.py
@@ -956,7 +956,13 @@ Warning: setting this to too low will result in lots of payment failures."""),
# zeroconf channels
OPEN_ZEROCONF_CHANNELS = ConfigVar('open_zeroconf_channels', default=False, type_=bool)
ZEROCONF_TRUSTED_NODE = ConfigVar('zeroconf_trusted_node', default='', type_=str)
+ # minimum absolute fee in sat for which we will open a channel just in time
ZEROCONF_MIN_OPENING_FEE = ConfigVar('zeroconf_min_opening_fee', default=5000, type_=int)
+ # fee in ppm of the outgoing htlcs value we charge for opening new channels just in time
+ ZEROCONF_OPENING_FEE_PPM = ConfigVar('zeroconf_opening_fee_ppm', default=10_000, type_=int)
+ # size of the channel the lsp opens to the client in percent of the outgoing htlcs value
+ # (before deducting fees). required to be at least 120% to leave some buffer for the channel reserve
+ ZEROCONF_CHANNEL_SIZE_PERCENT = ConfigVar('zeroconf_channel_size_percent', default=200, type_=int)
LN_UTXO_RESERVE = ConfigVar(
'ln_utxo_reserve',
default=10000,
diff --git a/tests/test_lnwallet.py b/tests/test_lnwallet.py
index 3fbc544..d35c90d 100644
--- a/tests/test_lnwallet.py
+++ b/tests/test_lnwallet.py
@@ -164,6 +164,7 @@ class TestLNWallet(ElectrumTestCase):
funding_tx = mock.Mock()
funding_tx.txid.return_value = os.urandom(32).hex()
+ funding_tx.get_fee = lambda: 250
wallet.open_channel_with_peer = mock.AsyncMock(return_value=(next_chan, funding_tx))
wallet.network.try_broadcasting = mock.AsyncMock(return_value=True)
Why this scored 33/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.