From 297aed99f04e625399728f7a5f35c3c7c6deae90 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 11 Dec 2025 14:59:31 +0100 Subject: [PATCH 1/9] lnpeer: check just-in-time channel opening fee Check the just-in-time channel opening fee when receiving an incoming channel opening. --- electrum/lnpeer.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index fc5d99b8796d..7abe8a02c361 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -1260,13 +1260,16 @@ async def on_open_channel(self, payload): # 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_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 + 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 - self.logger.info(f"just-in-time opening fee: {channel_opening_fee} msat") - pass + # the opening fee consists of the fee configured by the LSP + 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 + # we should compare against the fees they announced here. + raise Exception(f"{channel_opening_fee_sat=} exceeding fee limit, rejecting channel ({funding_sat=})") + self.logger.info(f"just-in-time channel: {channel_opening_fee_sat=}") if channel_type & ChannelType.OPTION_ANCHORS_ZERO_FEE_HTLC_TX: multisig_funding_keypair = lnutil.derive_multisig_funding_key_if_they_opened( From 1f17574dfad1cb2b177c3de22a1fac1c3b868909 Mon Sep 17 00:00:00 2001 From: f321x Date: Mon, 2 Feb 2026 17:56:25 +0100 Subject: [PATCH 2/9] lnchannel: fix update_unfunded_state, add unittest Fixes AbstractChannel.update_unfunded_state to stop calling a non-existent method (unwatch_channel). Adds unittest to execute the zeroconf path of update_unfunded_state. --- electrum/lnchannel.py | 50 ++++++++++++++++++++------------ tests/test_lnchannel.py | 64 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 20 deletions(-) diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py index bf9b80717780..3db9173c3466 100644 --- a/electrum/lnchannel.py +++ b/electrum/lnchannel.py @@ -41,7 +41,7 @@ from .crypto import sha256, sha256d from .transaction import Transaction, PartialTransaction, TxInput, Sighash from .logging import Logger -from .lntransport import LNPeerAddr +from .lntransport import LNPeerAddr, extract_nodeid, ConnStringFormatError from .lnonion import OnionRoutingFailure from . import lnutil from .lnutil import (Outpoint, LocalConfig, RemoteConfig, Keypair, OnlyPubkeyKeypair, ChannelConstraints, @@ -59,7 +59,7 @@ from .lnsweep import sweep_their_ctx_to_remote_backup from .lnhtlc import HTLCManager from .lnmsg import encode_msg, decode_msg -from .address_synchronizer import TX_HEIGHT_LOCAL +from .address_synchronizer import TX_HEIGHT_LOCAL, TX_HEIGHT_UNCONFIRMED from .lnutil import CHANNEL_OPENING_TIMEOUT_BLOCKS, CHANNEL_OPENING_TIMEOUT_SEC from .lnutil import ChannelBackupStorage, ImportedChannelBackupStorage, OnchainChannelBackupStorage from .lnutil import format_short_channel_id @@ -224,6 +224,7 @@ def get_state(self) -> ChannelState: return self._state def is_funded(self) -> bool: + # NOTE: also true for unfunded zeroconf channels (OPEN > FUNDED) return self.get_state() >= ChannelState.FUNDED def is_open(self) -> bool: @@ -375,26 +376,33 @@ def update_unfunded_state(self) -> None: self.logger.warning(f"dropping incoming channel, funding tx not found in mempool") self.lnworker.remove_channel(self.channel_id) elif self.is_zeroconf() and state in [ChannelState.OPEN, ChannelState.CLOSING, ChannelState.FORCE_CLOSING]: - chan_age = now() - self.storage['init_timestamp'] # handling zeroconf channels with no funding tx, can happen if broadcasting fails on LSP side - # or if the LSP did double spent the funding tx/never published it intentionally - # only remove a timed out OPEN channel if we are connected to the network to prevent removing it if we went - # offline before seeing the funding tx - if state != ChannelState.OPEN or chan_age > ZEROCONF_TIMEOUT and self.lnworker.network.is_connected(): - # we delete the channel if its in closing state (either initiated manually by client or by LSP on failure) - # or if the channel is not seeing any funding tx after 10 minutes to prevent further usage (limit damage) - self.set_state(ChannelState.REDEEMED, force=True) - local_balance_sat = int(self.balance(LOCAL) // 1000) - if local_balance_sat > 0: + # or if the LSP did double spent the funding tx/never published it intentionally. + if not self.lnworker.wallet.is_up_to_date() or not self.lnworker.network \ + or self.lnworker.network.blockchain().is_tip_stale(): + # ensure we are up to date to prevent accidentally dropping a channel that is funded + return + chan_age = now() - self.storage['init_timestamp'] + if chan_age > ZEROCONF_TIMEOUT: + # freeze the channel to avoid receiving even more into this unfunded channel. + # NOTE: we don't reject htlcs arriving on frozen channels, this only really + # stops us from including the channel in invoice routing hints. + if isinstance(self, Channel): + self.set_frozen_for_receiving(True) + + # un-trust the LSP so the user doesn't accept another channel from the same provider + # compare the node id's as the user might already have changed to another one + if self.node_id == self.lnworker.trusted_zeroconf_node_id: + self.lnworker.config.ZEROCONF_TRUSTED_NODE = '' + + if self.has_funding_timed_out(): + self.lnworker.remove_channel(self.channel_id) + # remove remaining local transactions from the wallet, this will also remove child transactions (closing tx) + # self.lnworker.lnwatcher.adb.remove_transaction(self.funding_outpoint.txid) + if (local_balance_sat := int(self.balance(LOCAL) // 1000)) > 0: self.logger.warning( f"we may have been scammed out of {local_balance_sat} sat by our " f"JIT provider: {self.lnworker.config.ZEROCONF_TRUSTED_NODE} or he didn't use our preimage") - self.lnworker.config.ZEROCONF_TRUSTED_NODE = '' - # FIXME this is broken: lnwatcher.unwatch_channel does not exist - self.lnworker.lnwatcher.unwatch_channel(self.get_funding_address(), self.funding_outpoint.to_str()) - # remove remaining local transactions from the wallet, this will also remove child transactions (closing tx) - self.lnworker.lnwatcher.adb.remove_transaction(self.funding_outpoint.txid) - self.lnworker.remove_channel(self.channel_id) def update_funded_state(self, *, funding_txid: str, funding_height: TxMinedInfo) -> None: self.save_funding_height(txid=funding_txid, height=funding_height.height(), timestamp=funding_height.timestamp) @@ -420,6 +428,9 @@ def update_funded_state(self, *, funding_txid: str, funding_height: TxMinedInfo) # remove zeroconf flag as we are now confirmed, this is to prevent an electrum server causing # us to remove a channel later in update_unfunded_state by omitting its funding tx self.remove_zeroconf_flag() + # unfreeze in case it was frozen in update_unfunded_state + if isinstance(self, Channel): + self.set_frozen_for_receiving(False) def update_closed_state(self, *, funding_txid: str, funding_height: TxMinedInfo, closing_txid: str, closing_height: TxMinedInfo, keep_watching: bool) -> None: @@ -843,7 +854,8 @@ def can_be_deleted(self) -> bool: return self.is_redeemed() def has_funding_timed_out(self): - if self.is_initiator() or self.is_funded(): + funding_height = self.get_funding_height() + if self.is_initiator() or funding_height and funding_height[1] > TX_HEIGHT_UNCONFIRMED: return False if self.lnworker.network.blockchain().is_tip_stale() or not self.lnworker.wallet.is_up_to_date(): return False diff --git a/tests/test_lnchannel.py b/tests/test_lnchannel.py index 7d74c54946d6..e3a89f06d484 100644 --- a/tests/test_lnchannel.py +++ b/tests/test_lnchannel.py @@ -40,7 +40,8 @@ from electrum.crypto import privkey_to_pubkey from electrum.lnutil import ( SENT, LOCAL, REMOTE, RECEIVED, UpdateAddHtlc, LnFeatures, secret_to_pubkey, ChannelType, - effective_htlc_tx_weight, LocalConfig, RemoteConfig, OnlyPubkeyKeypair, + effective_htlc_tx_weight, LocalConfig, RemoteConfig, OnlyPubkeyKeypair, ZEROCONF_TIMEOUT, + CHANNEL_OPENING_TIMEOUT_SEC, ) from electrum.logging import console_stderr_handler from electrum.lnchannel import ChannelState, Channel @@ -787,6 +788,67 @@ def test_unfunded_channel_can_be_removed(self): self.alice_channel._state = ChannelState.OPENING self.assertFalse(self.alice_channel.can_be_deleted()) + async def test_update_unfunded_zeroconf_channel(self): + """Cover the zeroconf branch of update_unfunded_state""" + chan = self.bob_channel + chan.set_state(ChannelState.OPEN, force=True) + bob = self.bob_lnwallet + self.assertFalse(chan.is_initiator()) + trusted_node = f"{chan.node_id.hex()}@127.0.0.1:9735" + chan.storage['channel_type'] |= ChannelType.OPTION_ZEROCONF + self.assertTrue(chan.is_zeroconf()) + # add channel to lnwallet/db + bob._channels[chan.channel_id] = chan + bob.db.get('channels')[chan.channel_id.hex()] = "something" + self.assertIsNotNone(bob.get_channel_by_id(chan.channel_id)) + chan.storage['init_height'] = 0 # checked by has_funding_timed_out + chan.storage['init_timestamp'] = int(time.time()) + self.assertEqual(chan.get_state(), ChannelState.OPEN) + self.assertEqual(chan.balance(LOCAL), 500000000000) + bob.config.ZEROCONF_TRUSTED_NODE = trusted_node + + chan.update_unfunded_state() + + # assert nothing happened + self.assertIsNotNone(bob.get_channel_by_id(chan.channel_id)) + self.assertIsNotNone(bob.db.get('channels').get(chan.channel_id.hex())) + self.assertEqual(chan.get_state(), ChannelState.OPEN) + self.assertEqual(bob.config.ZEROCONF_TRUSTED_NODE, trusted_node) + + # now time out zeroconf funding and try again, however her wallet is not up to date + chan.storage['init_timestamp'] -= ZEROCONF_TIMEOUT + 1 + bob.wallet.is_up_to_date = lambda: False + + chan.update_unfunded_state() + + # assert nothing happened again + self.assertIsNotNone(bob.get_channel_by_id(chan.channel_id)) + self.assertIsNotNone(bob.db.get('channels').get(chan.channel_id.hex())) + self.assertEqual(chan.get_state(), ChannelState.OPEN) + self.assertEqual(bob.config.ZEROCONF_TRUSTED_NODE, trusted_node) + self.assertFalse(chan.is_frozen_for_receiving()) + + # now her wallet is synced, and the channel is still unfunded + bob.wallet.is_up_to_date = lambda: True + + chan.update_unfunded_state() + + # check zeroconf provider gets unset + self.assertEqual(bob.config.ZEROCONF_TRUSTED_NODE, "") + self.assertFalse(chan.has_funding_timed_out()) + self.assertTrue(chan.is_frozen_for_receiving()) + + # time out funding (~2 weeks) + chan.storage['init_timestamp'] -= CHANNEL_OPENING_TIMEOUT_SEC + 1 + self.assertTrue(chan.has_funding_timed_out()) + + chan.update_unfunded_state() + + # check that channel got removed, now that funding has timed out + self.assertIsNone(self.alice_lnwallet.get_channel_by_id(chan.channel_id)) + self.assertIsNone(self.alice_lnwallet.db.get('channels').get(chan.channel_id.hex())) + + class TestChannelAnchors(TestChannel): TEST_ANCHOR_CHANNELS = True From 2da9fbbf156a7d09f5f76d47e0b5cf160e512822 Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 3 Feb 2026 10:44:25 +0100 Subject: [PATCH 3/9] lnworker/config: check if zeroconf is enabled when forwarding On LSP side we were only checking if ACCEPT_ZEROCONF_CHANNELS is enabled while forwarding a non-trampoline htlc. During trampoline forwarding the config was ignored. The ACCEPT_* prefix implied this was only for accepting inbound zeroconf channels, but it also controls whether we open them when forwarding HTLCs. Renames the config var to OPEN_ZEROCONF_CHANNELS to clarify it enables zeroconf channel opens in both directions, and add the missing check when forwarding trampoline HTLCs. --- electrum/lnworker.py | 7 ++++--- electrum/simple_config.py | 2 +- electrum/wallet.py | 2 +- tests/regtest.py | 8 ++++---- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index b532e5a194ab..8e39e7889711 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -1010,7 +1010,7 @@ def __init__(self, wallet: 'Abstract_Wallet', xprv, *, features: LnFeatures = No features = LNWALLET_FEATURES if self.config.ENABLE_ANCHOR_CHANNELS: features |= LnFeatures.OPTION_ANCHORS_ZERO_FEE_HTLC_OPT - if self.config.ACCEPT_ZEROCONF_CHANNELS: + if self.config.OPEN_ZEROCONF_CHANNELS: features |= LnFeatures.OPTION_ZEROCONF_OPT if self.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS or self.config.EXPERIMENTAL_LN_FORWARD_TRAMPOLINE_PAYMENTS: features |= LnFeatures.OPTION_ONION_MESSAGE_OPT @@ -1485,6 +1485,7 @@ async def open_channel_just_in_time( payment_hash: bytes, next_onion: OnionPacket, ) -> str: + assert self.config.OPEN_ZEROCONF_CHANNELS # if an exception is raised during negotiation, we raise an OnionRoutingFailure. # this will cancel the incoming HTLC @@ -3351,7 +3352,7 @@ def receive_requires_jit_channel(self, amount_msat: Optional[int]) -> bool: return False def can_get_zeroconf_channel(self) -> bool: - if not self.config.ACCEPT_ZEROCONF_CHANNELS and self.config.ZEROCONF_TRUSTED_NODE: + if not self.config.OPEN_ZEROCONF_CHANNELS and self.config.ZEROCONF_TRUSTED_NODE: # check if zeroconf is accepted and client has trusted zeroconf node configured return False try: @@ -3998,7 +3999,7 @@ async def _maybe_forward_trampoline( # do we have a connection to the node? next_peer = self.lnpeermgr.get_peer_by_pubkey(outgoing_node_id) - if next_peer and next_peer.accepts_zeroconf(): + if next_peer and next_peer.accepts_zeroconf() and self.features.supports(LnFeatures.OPTION_ZEROCONF_OPT): self.logger.info(f'JIT: found next_peer') for next_chan in next_peer.channels.values(): if next_chan.can_pay(amt_to_forward): diff --git a/electrum/simple_config.py b/electrum/simple_config.py index f1bdf240cb07..4918eb22d85d 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -954,7 +954,7 @@ def __setattr__(self, name, value): # anchor outputs channels ENABLE_ANCHOR_CHANNELS = ConfigVar('enable_anchor_channels', default=True, type_=bool) # zeroconf channels - ACCEPT_ZEROCONF_CHANNELS = ConfigVar('accept_zeroconf_channels', default=False, type_=bool) + OPEN_ZEROCONF_CHANNELS = ConfigVar('open_zeroconf_channels', default=False, type_=bool) ZEROCONF_TRUSTED_NODE = ConfigVar('zeroconf_trusted_node', default='', type_=str) ZEROCONF_MIN_OPENING_FEE = ConfigVar('zeroconf_min_opening_fee', default=5000, type_=int) LN_UTXO_RESERVE = ConfigVar( diff --git a/electrum/wallet.py b/electrum/wallet.py index 00f5c93f3c3f..e0c1292704c1 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -3458,7 +3458,7 @@ def get_help_texts_for_receive_request(self, req: Request) -> ReceiveRequestHelp zeroconf_nodeid = extract_nodeid(self.config.ZEROCONF_TRUSTED_NODE)[0] except Exception: zeroconf_nodeid = None - can_get_zeroconf_channel = (self.lnworker and self.config.ACCEPT_ZEROCONF_CHANNELS + 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) status = self.get_invoice_status(req) diff --git a/tests/regtest.py b/tests/regtest.py index 6c0953ec2f05..ae18044d8673 100644 --- a/tests/regtest.py +++ b/tests/regtest.py @@ -151,12 +151,12 @@ def test_fw_fail_htlc(self): class TestLightningJIT(TestLightning): agents = { 'alice': { - 'accept_zeroconf_channels': 'true', + 'open_zeroconf_channels': 'true', }, 'bob': { 'lightning_listen': 'localhost:9735', 'lightning_forward_payments': 'true', - 'accept_zeroconf_channels': 'true', + 'open_zeroconf_channels': 'true', }, 'carol': { } @@ -170,13 +170,13 @@ class TestLightningJITTrampoline(TestLightningJIT): agents = { 'alice': { 'use_gossip': 'false', - 'accept_zeroconf_channels': 'true', + 'open_zeroconf_channels': 'true', }, 'bob': { 'lightning_listen': 'localhost:9735', 'lightning_forward_payments': 'true', 'lightning_forward_trampoline_payments': 'true', - 'accept_zeroconf_channels': 'true', + 'open_zeroconf_channels': 'true', }, 'carol': { 'use_gossip': 'false', From f56e1cafac6e652550e7df0dbb23026b68eb1ccc Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 3 Feb 2026 12:26:04 +0100 Subject: [PATCH 4/9] lnworker: stop setting static jit alias for jit channel ...so we can have multiple just in time channels with the same lsp. We already save a remote scid alias in `on_channel_ready` which we already have received after the new zeroconf channel is in open state. So setting the alias to the static node id hash is counterproductive because it doesn't allow to differentiate between channels. Also extends the regtest (`just_in_time`) to do a second channel opening, to cover this scenario. This doesn't add much runtime to the test, so the cost seems reasonable. --- electrum/lnworker.py | 3 +-- tests/regtest/regtest.sh | 22 ++++++++++++++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 8e39e7889711..5bc933a4b5bd 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -1511,8 +1511,7 @@ async def wait_for_channel(): while not next_chan.is_open(): await asyncio.sleep(1) await util.wait_for2(wait_for_channel(), LN_P2P_NETWORK_TIMEOUT) - next_chan.save_remote_scid_alias(self._scid_alias_of_node(next_peer.pubkey)) - self.logger.info(f'JIT channel is open') + self.logger.info(f'JIT channel is open (will forward htlc and await preimage now)') next_amount_msat_htlc -= channel_opening_fee # fixme: some checks are missing htlc = next_peer.send_htlc( diff --git a/tests/regtest/regtest.sh b/tests/regtest/regtest.sh index e51af808c6fa..f9f19751c01c 100755 --- a/tests/regtest/regtest.sh +++ b/tests/regtest/regtest.sh @@ -779,10 +779,24 @@ if [[ $1 == "just_in_time" ]]; then echo "carol pays alice" # note: set amount to 0.001 to test failure: 'payment too low' invoice=$($alice add_request 0.01 --lightning --memo "invoice" | jq -r ".lightning_invoice") - success=$($carol lnpay $invoice| jq '.success') - if [[ $success != "true" ]]; then - echo "JIT payment failed" - exit 1 + success=$($carol lnpay $invoice | jq -r ".success") + if [[ "$success" != "true" ]]; then + echo "jit payment failed" + exit 1 + fi + # try again, multiple jit openings should work without issues + new_blocks 3 + echo "carol pays alice again" + invoice=$($alice add_request 0.04 --lightning --memo "invoice2" | jq -r ".lightning_invoice") + success=$($carol lnpay $invoice | jq -r ".success") + if [[ "$success" != "true" ]]; then + echo "jit payment failed" + exit 1 + fi + alice_chan_count=$($alice list_channels | jq '. | length') + if [[ "$alice_chan_count" != "2" ]]; then + echo "alice should have two jit channels" + exit 1 fi fi From 2eac67b4b8493a2c9d32ec4bb6b61fdd0f98b98b Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 3 Feb 2026 14:14:08 +0100 Subject: [PATCH 5/9] open_channel_just_in_time: add cleanup and broadcast retry Adds cleanup logic to `LNWallet.open_channel_just_in_time` so that the channel provider removes unfunded channels again, e.g. if the client didn't release the preimage or the provider failed to broadcast the funding transaction. Also adds more robust transaction broadcast logic so we retry to broadcast if it failed and check against adb to see if any previous broadcast was successful. --- electrum/lnworker.py | 51 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 5bc933a4b5bd..d9b8ac6f3eb7 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -74,7 +74,7 @@ OnchainChannelBackupStorage, ln_compare_features, IncompatibleLightningFeatures, PaymentFeeBudget, NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE, GossipForwardingMessage, MIN_FUNDING_SAT, MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE, RecvMPPResolution, ReceivedMPPStatus, ReceivedMPPHtlc, - PaymentSuccess, ChannelType, LocalConfig, Keypair, + PaymentSuccess, ChannelType, LocalConfig, Keypair, ZEROCONF_TIMEOUT, ) from .lnonion import ( decode_onion_error, OnionFailureCode, OnionRoutingFailure, OnionPacket, @@ -1489,6 +1489,7 @@ async def open_channel_just_in_time( # if an exception is raised during negotiation, we raise an OnionRoutingFailure. # this will cancel the incoming HTLC + next_chan: Optional[Channel] = None # 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: @@ -1525,12 +1526,31 @@ async def wait_for_preimage(): await asyncio.sleep(1) await util.wait_for2(wait_for_preimage(), LN_P2P_NETWORK_TIMEOUT) - # We have been paid and can broadcast - # todo: if broadcasting raise an exception, we should try to rebroadcast - await self.network.broadcast_transaction(funding_tx) - except OnionRoutingFailure: - raise - except Exception: + # We have been paid and can broadcast. + # Channel providers should run their own, trusted Electrum server as + # we could lose funds here if the server broadcasts the tx but omits it from us + first_broadcast_ts = time.time() + while time.time() - first_broadcast_ts < ZEROCONF_TIMEOUT * 0.75: + if await self.network.try_broadcasting(funding_tx, "jit channel funding"): + break + await asyncio.sleep(30) + # we cannot rely on success of try_broadcasting to determine broadcasting success + # as broadcasting might fail with some harmless error like 'transaction already in mempool' + tx_mined_info = self.wallet.adb.get_tx_height(funding_tx.txid()) + if tx_mined_info.height() > TX_HEIGHT_LOCAL: + self.logger.debug(f"found our jit channel funding tx: {tx_mined_info.height()=}") + break + else: + raise OnionRoutingFailure( + code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, + data=b'failed to broadcast funding transaction', + ) + except Exception as e: + if next_chan: + await self._cleanup_failed_jit_channel(next_chan) + self._preimages.pop(payment_hash.hex(), None) + if isinstance(e, OnionRoutingFailure): + raise raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') finally: del self.dont_settle_htlcs[payment_hash.hex()] @@ -1538,6 +1558,23 @@ async def wait_for_preimage(): htlc_key = serialize_htlc_key(next_chan.get_scid_or_local_alias(), htlc.htlc_id) return htlc_key + async def _cleanup_failed_jit_channel(self, chan: Channel): + """ + Removes a just in time channel where we didn't broadcast the funding + transaction, e.g. when the client didn't release the preimage. + """ + funding_height = chan.get_funding_height() + if funding_height is not None and funding_height[1] > TX_HEIGHT_LOCAL: + raise Exception("must not delete the channel if it has been broadcast") + # try to be nice and send shutdown to signal peer that this channel is dead + try: + await util.wait_for2(self.close_channel(chan.channel_id), LN_P2P_NETWORK_TIMEOUT) + except Exception: + self.logger.debug(f"sending chan shutdown to failed zeroconf peer failed ", exc_info=True) + chan.set_state(ChannelState.REDEEMED, force=True) + self.lnwatcher.adb.remove_transaction(chan.funding_outpoint.txid) + self.remove_channel(chan.channel_id) + @log_exceptions async def open_channel_with_peer( self, peer, funding_sat, *, From a3f12506cedaeafb927fae55882a53bade285bad Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 3 Feb 2026 15:13:18 +0100 Subject: [PATCH 6/9] tests: add unittests for LNWallet just in time opening Adds unittests for `LNWallet.open_channel_just_in_time()`, `LNWallet._cleanup_failed_jit_channel()`, `LNWallet.can_get_zeroconf_channel()` and `LNWallet.receive_requires_jit_channel()`. --- tests/test_lnwallet.py | 274 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 272 insertions(+), 2 deletions(-) diff --git a/tests/test_lnwallet.py b/tests/test_lnwallet.py index 1b7637dee0a7..3fbc54479788 100644 --- a/tests/test_lnwallet.py +++ b/tests/test_lnwallet.py @@ -1,14 +1,22 @@ import logging import os +import asyncio +from unittest import mock +from decimal import Decimal +from electrum.address_synchronizer import TX_HEIGHT_LOCAL import electrum.trampoline from . import ElectrumTestCase from .test_lnchannel import create_test_channels -from electrum.lnutil import RECEIVED, MIN_FINAL_CLTV_DELTA_ACCEPTED, LnFeatures -from electrum.lntransport import LNPeerAddr +from electrum.lnutil import RECEIVED, MIN_FINAL_CLTV_DELTA_ACCEPTED, serialize_htlc_key, LnFeatures from electrum.logging import console_stderr_handler +from electrum.lntransport import LNPeerAddr from electrum.invoices import LN_EXPIRY_NEVER, PR_UNPAID +from electrum.lnpeer import Peer +from electrum.lnchannel import Channel, ChannelState +from electrum.lnonion import OnionPacket, OnionRoutingFailure +from electrum.crypto import sha256 class TestLNWallet(ElectrumTestCase): @@ -144,3 +152,265 @@ async def test_trampoline_invoice_features_and_routing_hints(self): lnaddr3, _ = wallet.get_bolt11_invoice(payment_info=pi2, message='test', fallback_address=None) hint_node_ids3 = {route[0][0] for route in lnaddr3.get_routing_info('r')} self.assertEqual(hint_node_ids3, {trampoline_pubkey}) + + async def test_open_channel_just_in_time_success(self): + wallet = self.lnwallet_anchors + wallet.config.ZEROCONF_MIN_OPENING_FEE = 0 + wallet.config.OPEN_ZEROCONF_CHANNELS = True + + next_peer = mock.Mock(spec=Peer) + next_chan = mock.Mock(spec=Channel) + next_chan.get_scid_or_local_alias.return_value = bytes(8) + + funding_tx = mock.Mock() + funding_tx.txid.return_value = os.urandom(32).hex() + + wallet.open_channel_with_peer = mock.AsyncMock(return_value=(next_chan, funding_tx)) + wallet.network.try_broadcasting = mock.AsyncMock(return_value=True) + + preimage = os.urandom(32) + payment_hash = sha256(preimage) + + htlc = mock.Mock() + htlc.htlc_id = 0 + next_peer.send_htlc.return_value = htlc + + task = asyncio.create_task(wallet.open_channel_just_in_time( + next_peer=next_peer, + next_amount_msat_htlc=1000000, + next_cltv_abs=500, + payment_hash=payment_hash, + next_onion=mock.Mock(spec=OnionPacket) + )) + + await asyncio.sleep(0.1) + wallet.save_preimage(payment_hash, preimage) + htlc_key = await task + htlc_key_correct = serialize_htlc_key(next_chan.get_scid_or_local_alias(), htlc.htlc_id) + self.assertEqual(htlc_key, htlc_key_correct) + + wallet.open_channel_with_peer.assert_called_once() + next_peer.send_htlc.assert_called_once() + wallet.network.try_broadcasting.assert_called() + + async def test_open_channel_just_in_time_failure_channel_open(self): + """The channel opening failed on the LSP side because the client rejected the incoming channel""" + wallet = self.lnwallet_anchors + wallet.config.ZEROCONF_MIN_OPENING_FEE = 0 + wallet.config.OPEN_ZEROCONF_CHANNELS = True + next_peer = mock.Mock(spec=Peer) + wallet.open_channel_with_peer = mock.AsyncMock(side_effect=Exception("peer rejected incoming channel")) + preimage = os.urandom(32) + wallet.save_preimage(sha256(preimage), preimage) + wallet._cleanup_failed_jit_channel = mock.AsyncMock() + + with self.assertRaises(OnionRoutingFailure): + await wallet.open_channel_just_in_time( + next_peer=next_peer, + next_amount_msat_htlc=1000000, + next_cltv_abs=500, + payment_hash=sha256(preimage), + next_onion=mock.Mock(spec=OnionPacket) + ) + + self.assertIsNone(wallet.get_preimage(sha256(preimage))) + wallet._cleanup_failed_jit_channel.assert_not_called() + + async def test_open_channel_just_in_time_failure_send_htlc(self): + """The LSP fails to forward the htlc to the client""" + wallet = self.lnwallet_anchors + wallet.config.ZEROCONF_MIN_OPENING_FEE = 0 + wallet.config.OPEN_ZEROCONF_CHANNELS = True + + next_peer = mock.Mock(spec=Peer) + chan = mock.Mock(spec=Channel) + funding_tx = mock.Mock() + + wallet.open_channel_with_peer = mock.AsyncMock(return_value=(chan, funding_tx)) + next_peer.send_htlc.side_effect = Exception("couldn't send htlc, peer disconnected") + preimage = os.urandom(32) + wallet.save_preimage(sha256(preimage), preimage) + wallet._cleanup_failed_jit_channel = mock.AsyncMock() + + with self.assertRaises(OnionRoutingFailure): + await wallet.open_channel_just_in_time( + next_peer=next_peer, + next_amount_msat_htlc=1000000, + next_cltv_abs=500, + payment_hash=sha256(preimage), + next_onion=mock.Mock(spec=OnionPacket) + ) + + self.assertIsNone(wallet.get_preimage(sha256(preimage))) + wallet._cleanup_failed_jit_channel.assert_called_once_with(chan) + + async def test_open_channel_just_in_time_failure_preimage_timeout(self): + """The client never releases the preimage""" + wallet = self.lnwallet_anchors + wallet.config.ZEROCONF_MIN_OPENING_FEE = 0 + wallet.config.OPEN_ZEROCONF_CHANNELS = True + + next_peer = mock.Mock(spec=Peer) + chan = mock.Mock(spec=Channel) + funding_tx = mock.Mock() + + wallet.open_channel_with_peer = mock.AsyncMock(return_value=(chan, funding_tx)) + + htlc = mock.Mock() + next_peer.send_htlc.return_value = htlc + + wallet._cleanup_failed_jit_channel = mock.AsyncMock() + + with mock.patch('electrum.lnworker.LN_P2P_NETWORK_TIMEOUT', 0.01): + with self.assertRaises(OnionRoutingFailure): + await wallet.open_channel_just_in_time( + next_peer=next_peer, + next_amount_msat_htlc=1000000, + next_cltv_abs=500, + payment_hash=os.urandom(32), + next_onion=mock.Mock(spec=OnionPacket) + ) + + wallet._cleanup_failed_jit_channel.assert_called_once_with(chan) + + async def test_open_channel_just_in_time_failure_broadcast(self): + wallet = self.lnwallet_anchors + wallet.config.ZEROCONF_MIN_OPENING_FEE = 0 + wallet.config.OPEN_ZEROCONF_CHANNELS = True + + next_peer = mock.Mock(spec=Peer) + chan = mock.Mock(spec=Channel) + + funding_tx = mock.Mock() + + wallet.open_channel_with_peer = mock.AsyncMock(return_value=(chan, funding_tx)) + + preimage = os.urandom(32) + wallet.save_preimage(sha256(preimage), preimage) + + wallet.network.try_broadcasting = mock.AsyncMock(return_value=False) + wallet.wallet.adb.get_tx_height = mock.Mock(return_value=mock.Mock(height=lambda: TX_HEIGHT_LOCAL)) + + wallet._cleanup_failed_jit_channel = mock.AsyncMock() + + with mock.patch('electrum.lnworker.ZEROCONF_TIMEOUT', 0.01), \ + mock.patch('electrum.lnworker.asyncio.sleep', new_callable=mock.AsyncMock): + with self.assertRaises(OnionRoutingFailure): + await wallet.open_channel_just_in_time( + next_peer=next_peer, + next_amount_msat_htlc=1000000, + next_cltv_abs=500, + payment_hash=sha256(preimage), + next_onion=mock.Mock(spec=OnionPacket) + ) + + self.assertIsNone(wallet.get_preimage(sha256(preimage))) + wallet._cleanup_failed_jit_channel.assert_called_once_with(chan) + + async def test_open_channel_just_in_time_config_disabled(self): + """open_channel_just_in_time rejects to open a channel if the config is disabled""" + wallet = self.lnwallet_anchors + wallet.config.ZEROCONF_MIN_OPENING_FEE = 0 + wallet.config.OPEN_ZEROCONF_CHANNELS = False + + with self.assertRaises(AssertionError): + await wallet.open_channel_just_in_time( + next_peer=mock.Mock(spec=Peer), + next_amount_msat_htlc=1000000, + next_cltv_abs=500, + payment_hash=os.urandom(32), + next_onion=mock.Mock(spec=OnionPacket) + ) + + async def test_cleanup_failed_jit_channel(self): + wallet = self.lnwallet_anchors + + chan = mock.Mock(spec=Channel) + chan_id = os.urandom(32).hex() + chan.channel_id = chan_id + funding_txid = os.urandom(32).hex() + chan.funding_outpoint = mock.Mock() + chan.funding_outpoint.txid = funding_txid + chan.get_funding_height.return_value = None + + # close_channel fails with exception + wallet.close_channel = mock.AsyncMock(side_effect=Exception("peer disconnected")) + wallet.remove_channel = mock.Mock() + wallet.lnwatcher = mock.Mock() + wallet.lnwatcher.adb = mock.Mock() + wallet.lnwatcher.adb.remove_transaction = mock.Mock() + + await wallet._cleanup_failed_jit_channel(chan) + + wallet.close_channel.assert_called_once_with(chan_id) + chan.set_state.assert_called_once_with(ChannelState.REDEEMED, force=True) + wallet.lnwatcher.adb.remove_transaction.assert_called_once_with(funding_txid) + wallet.remove_channel.assert_called_once_with(chan_id) + + async def test_receive_requires_jit_channel(self): + wallet = self.lnwallet_anchors + + with self.subTest(msg="cannot get jit channel"): + wallet.can_get_zeroconf_channel = mock.Mock(return_value=False) + wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(0)) + self.assertFalse(wallet.receive_requires_jit_channel(1_000_000)) + + with self.subTest(msg="could get zeroconf channel but doesn't need one"): + wallet.can_get_zeroconf_channel = mock.Mock(return_value=True) + wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(2000)) + self.assertFalse(wallet.receive_requires_jit_channel(1_000_000)) + + with self.subTest(msg="could get zeroconf channel and needs one"): + wallet.can_get_zeroconf_channel = mock.Mock(return_value=True) + wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(500)) + self.assertTrue(wallet.receive_requires_jit_channel(1_000_000)) + + with self.subTest(msg="could get one but can receive exactly the requested amount"): + wallet.can_get_zeroconf_channel = mock.Mock(return_value=True) + wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(1000)) + self.assertFalse(wallet.receive_requires_jit_channel(1_000_000)) + + with self.subTest(msg="0 amount invoice, could get channel but can receive something"): + wallet.can_get_zeroconf_channel = mock.Mock(return_value=True) + wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(1)) + self.assertFalse(wallet.receive_requires_jit_channel(None)) + + with self.subTest(msg="0 amount invoice (None amount), cannot receive anything and can get channel"): + wallet.can_get_zeroconf_channel = mock.Mock(return_value=True) + wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(0)) + self.assertTrue(wallet.receive_requires_jit_channel(None)) + + with self.subTest(msg="0 amount invoice (0 msat), cannot receive anything, could get channel"): + wallet.can_get_zeroconf_channel = mock.Mock(return_value=True) + wallet.num_sats_can_receive = mock.Mock(return_value=Decimal(0)) + self.assertTrue(wallet.receive_requires_jit_channel(0)) + + async def test_can_get_zeroconf_channel(self): + wallet = self.lnwallet_anchors + valid_peer = "02" * 33 + "@localhost:9735" + + with self.subTest(msg="disabled in config"): + wallet.config.OPEN_ZEROCONF_CHANNELS = False + wallet.config.ZEROCONF_TRUSTED_NODE = valid_peer + self.assertFalse(wallet.can_get_zeroconf_channel()) + + with self.subTest(msg="enabled, but no trusted node configured"): + wallet.config.OPEN_ZEROCONF_CHANNELS = True + wallet.config.ZEROCONF_TRUSTED_NODE = '' + self.assertFalse(wallet.can_get_zeroconf_channel()) + + with self.subTest(msg="enabled, invalid trusted node string"): + wallet.config.OPEN_ZEROCONF_CHANNELS = True + wallet.config.ZEROCONF_TRUSTED_NODE = "invalid_node_string" + self.assertFalse(wallet.can_get_zeroconf_channel()) + + with self.subTest(msg="enabled, valid trusted node, but not connected"): + wallet.config.OPEN_ZEROCONF_CHANNELS = True + wallet.config.ZEROCONF_TRUSTED_NODE = valid_peer + self.assertFalse(wallet.can_get_zeroconf_channel()) + + with self.subTest(msg="enabled, valid trusted node, and connected"): + wallet.lnpeermgr.get_peer_by_pubkey = mock.Mock(return_value=mock.Mock(spec=Peer)) + wallet.config.OPEN_ZEROCONF_CHANNELS = True + wallet.config.ZEROCONF_TRUSTED_NODE = valid_peer + self.assertTrue(wallet.can_get_zeroconf_channel()) From 85356e55446ad8d12e1e9b9cf75b2dd0383e0c0b Mon Sep 17 00:00:00 2001 From: f321x Date: Tue, 3 Feb 2026 17:37:29 +0100 Subject: [PATCH 7/9] lnwallet: make jit fees configurable, add mining fees Make the just in time channel fees and channel size configvars, as in practice not every provider would use the same hardcoded fees or channel sizes. Add the mining fees required for the funding transaction on top of the opening fees to prevent opening channels at a loss in a higher fee environment. --- electrum/lnpeer.py | 2 +- electrum/lnworker.py | 29 ++++++++++++++++++++--------- electrum/simple_config.py | 6 ++++++ tests/test_lnwallet.py | 1 + 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 7abe8a02c361..5375762520fb 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -1263,7 +1263,7 @@ async def on_open_channel(self, payload): 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 d9b8ac6f3eb7..7fb38d272ee8 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -1493,19 +1493,22 @@ async def open_channel_just_in_time( # 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 @@ async def wait_for_channel(): 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 @@ async def wait_for_preimage(): 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 @@ async def open_channel_with_peer( 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 @@ async def open_channel_with_peer( 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 @@ async def open_channel_with_peer( 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 4918eb22d85d..56bfca4beeb8 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -956,7 +956,13 @@ def __setattr__(self, name, value): # 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 3fbc54479788..d35c90d96ab9 100644 --- a/tests/test_lnwallet.py +++ b/tests/test_lnwallet.py @@ -164,6 +164,7 @@ async def test_open_channel_just_in_time_success(self): 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) From a06c8bacc3afe4a6632b9f04a8e1751b05a54a44 Mon Sep 17 00:00:00 2001 From: f321x Date: Wed, 4 Feb 2026 15:17:22 +0100 Subject: [PATCH 8/9] lnpeer: don't signal OPTION_ZEROCONF_OPT to untrusted peer Only signal `OPTION_ZEROCONF_OPT` to peers if we either: 1. Have no trusted peer configured (assuming that we are LSP) 2. Have a trusted peer configured, and the peer we are connecting to is this trusted peer. Otherwise peers that are LSPs but are not the clients trusted LSP might try to open a channel to the client but it would get rejected. --- electrum/lnpeer.py | 5 +++++ electrum/lnworker.py | 19 +++++++++++------ tests/test_lnpeer.py | 49 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 5375762520fb..bf8ec005ac37 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -101,6 +101,11 @@ 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 \ + lnworker.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 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] diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 7fb38d272ee8..f0435d1eb9ae 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -3399,17 +3399,24 @@ def receive_requires_jit_channel(self, amount_msat: Optional[int]) -> bool: return False def can_get_zeroconf_channel(self) -> bool: - if not self.config.OPEN_ZEROCONF_CHANNELS and self.config.ZEROCONF_TRUSTED_NODE: - # check if zeroconf is accepted and client has trusted zeroconf node configured + if not self.config.OPEN_ZEROCONF_CHANNELS: return False - try: - node_id = extract_nodeid(self.config.ZEROCONF_TRUSTED_NODE)[0] - except ConnStringFormatError: - # invalid connection string + node_id = self.trusted_zeroconf_node_id + if not node_id: return False # only return True if we are connected to the zeroconf provider return self.lnpeermgr.get_peer_by_pubkey(node_id) is not None + @property + def trusted_zeroconf_node_id(self) -> Optional[bytes]: + if not self.config.ZEROCONF_TRUSTED_NODE: + return None + try: + return extract_nodeid(self.config.ZEROCONF_TRUSTED_NODE)[0] + except ConnStringFormatError: + self.logger.warning(f"invalid zeroconf node connection string configured") + return None + def _suggest_channels_for_rebalance(self, direction, amount_sat) -> Sequence[Tuple[Channel, int]]: """ Suggest a channel and amount to send/receive with that channel, so that we will be able to receive/send amount_sat diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index 8669931c24fc..7af6087787cb 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -634,6 +634,55 @@ async def test_maybe_save_remote_update(self): with self.assertRaises(InvalidGossipMsg): ChannelDB.verify_channel_update(payload, start_node=alice_bob_peer.pubkey) + async def test_zeroconf_feature_bit(self): + workers = self.prepare_lnwallets(self.GRAPH_DEFINITIONS['single_chan']) + + with self.subTest(msg="zeroconf is disabled in Alice LNWallet, so peers shouldn't signal it either"): + graph = self.prepare_chans_and_peers_in_graph( + self.GRAPH_DEFINITIONS['single_chan'], + workers=workers, + ) + alice, _ = graph.peers.values() + self.assertFalse(alice.features.supports(LnFeatures.OPTION_ZEROCONF_OPT)) + + # enable zeroconf in alice LNWallet + workers['alice'].features |= LnFeatures.OPTION_ZEROCONF_OPT + + with self.subTest(msg="no trusted zeroconf node, zeroconf should be signaled in new peers"): + graph = self.prepare_chans_and_peers_in_graph( + self.GRAPH_DEFINITIONS['single_chan'], + workers=workers, + ) + alice, _ = graph.peers.values() # alice is LSP + self.assertTrue(alice.features.supports(LnFeatures.OPTION_ZEROCONF_OPT)) + + with self.subTest(msg="trusted node is configured, but it is not bob"): + workers['alice'].config.ZEROCONF_TRUSTED_NODE = f"{os.urandom(33).hex()}@1.1.1.1:9735" + graph = self.prepare_chans_and_peers_in_graph( + self.GRAPH_DEFINITIONS['single_chan'], + workers=workers, + ) + alice, _ = graph.peers.values() # alice is client + self.assertFalse(alice.features.supports(LnFeatures.OPTION_ZEROCONF_OPT)) + + with self.subTest(msg="trusted node is configured, but it is invalid"): + workers['alice'].config.ZEROCONF_TRUSTED_NODE = f"{os.urandom(8).hex()}@1.1.1.1:9735" + graph = self.prepare_chans_and_peers_in_graph( + self.GRAPH_DEFINITIONS['single_chan'], + workers=workers, + ) + alice, _ = graph.peers.values() # alice is client + self.assertFalse(alice.features.supports(LnFeatures.OPTION_ZEROCONF_OPT)) + + with self.subTest(msg="Alice uses Bob as her trusted LSP"): + workers['alice'].config.ZEROCONF_TRUSTED_NODE = workers['bob'].node_keypair.pubkey.hex() + graph = self.prepare_chans_and_peers_in_graph( + self.GRAPH_DEFINITIONS['single_chan'], + workers=workers, + ) + alice, _ = graph.peers.values() + self.assertTrue(alice.features.supports(LnFeatures.OPTION_ZEROCONF_OPT)) + class TestPeerDirect(TestPeer): From a4af5cf48ac07c1fc8b53f0b0f17e63cc0d49fa0 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 5 Feb 2026 17:10:34 +0100 Subject: [PATCH 9/9] qt: ReceiveTab: fix flickering zeroconf message The ReceiveTab gets updated regularly (e.g. when syncing headers). Every time it updates we would first show the invoice and then the zeroconf confirmation overlay. This caused the overly to appear flickering when there are updates in higher frequency. Also we need to keep state if the user has already confirmed the zeroconf message for this request, otherwise the question will re-appear each time the user clicked "Accept" and the ReceiveTab updates again. --- electrum/gui/qt/receive_tab.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/electrum/gui/qt/receive_tab.py b/electrum/gui/qt/receive_tab.py index 74377b319f6b..fe60c56989bb 100644 --- a/electrum/gui/qt/receive_tab.py +++ b/electrum/gui/qt/receive_tab.py @@ -20,6 +20,7 @@ if TYPE_CHECKING: from .main_window import ElectrumWindow + from electrum.wallet import Request class ReceiveTab(QWidget, MessageBoxMixin, Logger): @@ -101,6 +102,9 @@ def __init__(self, window: 'ElectrumWindow'): self.receive_zeroconf_button = QPushButton(_('Accept')) self.receive_zeroconf_button.clicked.connect(self.on_accept_zeroconf) + self.previous_request = None # type: Optional['Request'] + self.confirmed_zeroconf_for_this_request = False # type: bool + def on_receive_rebalance(): if self.receive_rebalance_button.suggestion: chan1, chan2, delta = self.receive_rebalance_button.suggestion @@ -221,7 +225,7 @@ def toggle_receive_qr(self): def update_receive_widgets(self): b = self.config.GUI_QT_RECEIVE_TAB_QR_VISIBLE - self.receive_widget.update_visibility(b) + self.receive_widget.update_visibility(b, bool(self.receive_help_text.text())) def update_current_request(self): if len(self.request_list.selectionModel().selectedRows(0)) > 1: @@ -229,6 +233,9 @@ def update_current_request(self): else: key = self.request_list.get_current_key() req = self.wallet.get_request(key) if key else None + if req != self.previous_request: + self.previous_request = req + self.confirmed_zeroconf_for_this_request = False if req is None: self.receive_e.setText('') self.addr = self.URI = self.lnaddr = '' @@ -243,7 +250,7 @@ def update_current_request(self): self.ln_help = help_texts.ln_help can_rebalance = help_texts.can_rebalance() can_swap = help_texts.can_swap() - can_zeroconf = help_texts.can_zeroconf() + can_zeroconf = help_texts.can_zeroconf() if not self.confirmed_zeroconf_for_this_request else False self.receive_rebalance_button.suggestion = help_texts.ln_rebalance_suggestion self.receive_swap_button.suggestion = help_texts.ln_swap_suggestion self.receive_rebalance_button.setVisible(can_rebalance) @@ -253,25 +260,26 @@ def update_current_request(self): self.receive_zeroconf_button.setVisible(can_zeroconf) self.receive_zeroconf_button.setEnabled(can_zeroconf) text, data, help_text, title = self.get_tab_data() + if self.confirmed_zeroconf_for_this_request and help_texts.can_zeroconf(): + help_text = '' + # set help before receive_e so we don't flicker from qr to help + self.receive_help_text.setText(help_text) self.receive_e.setText(text) self.receive_qr.setData(data) - self.receive_help_text.setText(help_text) for w in [self.receive_e, self.receive_qr]: w.setEnabled(bool(text) and (not help_text or can_zeroconf)) w.setToolTip(help_text) # macOS hack (similar to #4777) self.receive_e.repaint() # always show - if can_zeroconf: - # show the help message if zeroconf so user can first accept it and still sees the invoice - # after accepting - self.receive_widget.show_help() self.receive_widget.setVisible(True) self.toggle_qr_button.setEnabled(True) self.update_receive_qr_window() def on_accept_zeroconf(self): self.receive_zeroconf_button.setVisible(False) + self.confirmed_zeroconf_for_this_request = True + self.receive_help_text.setText('') self.update_receive_widgets() def get_tab_data(self): @@ -386,8 +394,8 @@ def __init__(self, receive_tab: 'ReceiveTab', textedit: QWidget, qr: QWidget, he self.setLayout(vbox) - def update_visibility(self, is_qr): - if str(self.textedit.toPlainText()): + def update_visibility(self, is_qr: bool, show_help: bool): + if str(self.textedit.toPlainText()) and not show_help: self.help_widget.setVisible(False) self.textedit.setVisible(not is_qr) self.qr.setVisible(is_qr)