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 fc5d99b87..7abe8a02c 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -1260,13 +1260,16 @@ 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_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 bf9b80717..3db9173c3 100644 --- a/electrum/lnchannel.py +++ b/electrum/lnchannel.py @@ -41,7 +41,7 @@ from .bitcoin import redeem_script_to_address 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_htlctx_justice, sweep_our_htlctx, SweepInfo, Ma 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 @@ class AbstractChannel(Logger, ABC): 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 @@ class AbstractChannel(Logger, ABC): 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 @@ class AbstractChannel(Logger, ABC): # 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 @@ class Channel(AbstractChannel): 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 7d74c5494..e3a89f06d 100644 --- a/tests/test_lnchannel.py +++ b/tests/test_lnchannel.py @@ -40,7 +40,8 @@ from electrum import lnutil 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 @@ class TestChannel(ElectrumTestCase): 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 b532e5a19..8e39e7889 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -1010,7 +1010,7 @@ class LNWallet(Logger): 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 @@ class LNWallet(Logger): 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 @@ class LNWallet(Logger): 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 @@ class LNWallet(Logger): # 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 f1bdf240c..4918eb22d 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -954,7 +954,7 @@ Warning: setting this to too low will result in lots of payment failures."""), # 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 00f5c93f3..e0c129270 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -3458,7 +3458,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): 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 6c0953ec2..ae18044d8 100644 --- a/tests/regtest.py +++ b/tests/regtest.py @@ -151,12 +151,12 @@ class TestLightningABC(TestLightning): 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 8e39e7889..5bc933a4b 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -1511,8 +1511,7 @@ class LNWallet(Logger): 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 e51af808c..f9f19751c 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 5bc933a4b..d9b8ac6f3 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -74,7 +74,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, 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 @@ class LNWallet(Logger): # 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 @@ class LNWallet(Logger): 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 @@ class LNWallet(Logger): 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 1b7637dee..3fbc54479 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 @@ class TestLNWallet(ElectrumTestCase): 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 7abe8a02c..537576252 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 d9b8ac6f3..7fb38d272 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 4918eb22d..56bfca4be 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 3fbc54479..d35c90d96 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) 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 537576252..bf8ec005a 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -101,6 +101,11 @@ class Peer(Logger, EventListener): 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 7fb38d272..f0435d1eb 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -3399,17 +3399,24 @@ class LNWallet(Logger): 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 8669931c2..7af608778 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -634,6 +634,55 @@ class TestPeerUtils(TestPeer): 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 74377b319..fe60c5698 100644 --- a/electrum/gui/qt/receive_tab.py +++ b/electrum/gui/qt/receive_tab.py @@ -20,6 +20,7 @@ from .util import read_QIcon, WWLabel, MessageBoxMixin, MONOSPACE_FONT, get_icon if TYPE_CHECKING: from .main_window import ElectrumWindow + from electrum.wallet import Request class ReceiveTab(QWidget, MessageBoxMixin, Logger): @@ -101,6 +102,9 @@ class ReceiveTab(QWidget, MessageBoxMixin, Logger): 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 @@ class ReceiveTab(QWidget, MessageBoxMixin, Logger): 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 @@ class ReceiveTab(QWidget, MessageBoxMixin, Logger): 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 @@ class ReceiveTab(QWidget, MessageBoxMixin, Logger): 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 @@ class ReceiveTab(QWidget, MessageBoxMixin, Logger): 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 @@ class ReceiveWidget(QWidget): 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)