From c34efce984433a627e9833edb460bbe4d4ab6389 Mon Sep 17 00:00:00 2001 From: f321x Date: Fri, 5 Dec 2025 14:42:08 +0100 Subject: [PATCH] lnchannel: allow deleting unfunded incoming channels We tried to delete incoming channels that didn't get funded after lnutil.CHANNEL_OPENING_TIMEOUT, however an assert prevented this: ``` 3.63 | E | lnwatcher.LNWatcher.[default_wallet-LNW] | Exception in check_onchain_situation: AssertionError() Traceback (most recent call last): File "/home/user/code/electrum-fork/electrum/util.py", line 1233, in wrapper return await func(*args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/user/code/electrum-fork/electrum/lnwatcher.py", line 117, in check_onchain_situation await self.update_channel_state( ...<5 lines>... keep_watching=keep_watching) File "/home/user/code/electrum-fork/electrum/lnwatcher.py", line 135, in update_channel_state chan.update_onchain_state( ~~~~~~~~~~~~~~~~~~~~~~~~~^ funding_txid=funding_txid, ^^^^^^^^^^^^^^^^^^^^^^^^^^ ...<2 lines>... closing_height=closing_height, ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ keep_watching=keep_watching) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/user/code/electrum-fork/electrum/lnchannel.py", line 341, in update_onchain_state self.update_unfunded_state() ~~~~~~~~~~~~~~~~~~~~~~~~~~^^ File "/home/user/code/electrum-fork/electrum/lnchannel.py", line 382, in update_unfunded_state self.lnworker.remove_channel(self.channel_id) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^ File "/home/user/code/electrum-fork/electrum/lnworker.py", line 3244, in remove_channel assert chan.can_be_deleted() ~~~~~~~~~~~~~~~~~~~^^ AssertionError ``` --- electrum/lnchannel.py | 40 +++++++++++++++++++++++++++++++-------- electrum/lnpeer.py | 2 ++ electrum/lnutil.py | 5 ++++- tests/test_lnchannel.py | 42 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 9 deletions(-) diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py index f211f27cd..446ecf3be 100644 --- a/electrum/lnchannel.py +++ b/electrum/lnchannel.py @@ -60,7 +60,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 .lnutil import CHANNEL_OPENING_TIMEOUT +from .lnutil import CHANNEL_OPENING_TIMEOUT_BLOCKS, CHANNEL_OPENING_TIMEOUT_SEC from .lnutil import ChannelBackupStorage, ImportedChannelBackupStorage, OnchainChannelBackupStorage from .lnutil import format_short_channel_id from .fee_policy import FEERATE_PER_KW_MIN_RELAY_LIGHTNING @@ -356,7 +356,6 @@ class AbstractChannel(Logger, ABC): self.delete_closing_height() if not self.lnworker: return - chan_age = now() - self.storage.get('init_timestamp', 0) state = self.get_state() if state in [ChannelState.PREOPENING, ChannelState.OPENING, ChannelState.FORCE_CLOSING]: if self.is_initiator(): @@ -377,11 +376,11 @@ class AbstractChannel(Logger, ABC): self.logger.info(f'channel is double spent {inputs}') self.set_state(ChannelState.REDEEMED) break - else: - if chan_age > CHANNEL_OPENING_TIMEOUT: - self.lnworker.remove_channel(self.channel_id) + elif self.has_funding_timed_out(): + 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]: - assert self.storage.get('init_timestamp') is not None, "init_timestamp not set for zeroconf channel" + 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 @@ -407,6 +406,10 @@ class AbstractChannel(Logger, ABC): if funding_height.conf>0: self.set_short_channel_id(ShortChannelID.from_components( funding_height.height(), funding_height.txpos, self.funding_outpoint.output_index)) + elif self.has_funding_timed_out(): + self.logger.warning("dropping incoming channel, funding tx took too long to confirm") + self.lnworker.remove_channel(self.channel_id) + return if self.get_state() == ChannelState.OPENING: if self.is_funding_tx_mined(funding_height): self.set_state(ChannelState.FUNDED) @@ -549,6 +552,10 @@ class AbstractChannel(Logger, ABC): def can_be_deleted(self) -> bool: pass + @abstractmethod + def has_funding_timed_out(self) -> bool: + pass + @abstractmethod def get_wallet_addresses_channel_might_want_reserved(self) -> Sequence[str]: """Returns a list of addrs that the wallet should not use, to avoid address-reuse. @@ -651,6 +658,9 @@ class ChannelBackup(AbstractChannel): def can_be_deleted(self): return self.is_imported or self.is_redeemed() + def has_funding_timed_out(self): + return False + def get_capacity(self): lnwatcher = self.lnworker.lnwatcher if lnwatcher: @@ -769,7 +779,7 @@ class Channel(AbstractChannel): self, state: 'StoredDict', *, name=None, - lnworker=None, + lnworker=None, # None only in unittests initial_feerate=None, jit_opening_fee: Optional[int] = None, ): @@ -833,9 +843,23 @@ class Channel(AbstractChannel): def has_onchain_backup(self): return self.storage.get('has_onchain_backup', False) - def can_be_deleted(self): + def can_be_deleted(self) -> bool: + if self.has_funding_timed_out(): + return True return self.is_redeemed() + def has_funding_timed_out(self): + if self.is_initiator() or self.is_funded(): + return False + if self.lnworker.network.blockchain().is_tip_stale() or not self.lnworker.wallet.is_up_to_date(): + return False + init_height = self.storage.get('init_height', 0) + init_timestamp = self.storage.get('init_timestamp', 0) + age_blocks = self.lnworker.network.get_local_height() - init_height + age_sec = now() - init_timestamp + # some channels might not have init_height set so we check both time and block based timeouts + return age_blocks > CHANNEL_OPENING_TIMEOUT_BLOCKS and age_sec > CHANNEL_OPENING_TIMEOUT_SEC + def get_capacity(self): return self.constraints.capacity diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index eaaddacfa..ca5929b85 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -1216,6 +1216,7 @@ class Peer(Logger, EventListener): ) chan.storage['funding_inputs'] = [txin.prevout.to_json() for txin in funding_tx.inputs()] chan.storage['has_onchain_backup'] = has_onchain_backup + chan.storage['init_height'] = self.lnworker.network.get_local_height() chan.storage['init_timestamp'] = int(time.time()) if isinstance(self.transport, LNTransport): chan.add_or_update_peer_addr(self.transport.peer_addr) @@ -1438,6 +1439,7 @@ class Peer(Logger, EventListener): initial_feerate=feerate, jit_opening_fee = channel_opening_fee, ) + chan.storage['init_height'] = self.lnworker.network.get_local_height() chan.storage['init_timestamp'] = int(time.time()) if isinstance(self.transport, LNTransport): chan.add_or_update_peer_addr(self.transport.peer_addr) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 70256a74d..50cfedb53 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -489,7 +489,10 @@ class LNProtocolWarning(Exception): # TODO make some of these values configurable? REDEEM_AFTER_DOUBLE_SPENT_DELAY = 30 -CHANNEL_OPENING_TIMEOUT = 24*60*60 +# timeout after which we forget incoming channels if the funding tx has no confirmation +# https://github.com/lightning/bolts/commit/ba00bf8f4cd85f21bacfc03adcafd4acc7d68382 +CHANNEL_OPENING_TIMEOUT_BLOCKS = 2016 +CHANNEL_OPENING_TIMEOUT_SEC = 14*24*60*60 # 2 weeks # Small capacity channels are problematic for many reasons. As the onchain fees start to become # significant compared to the capacity, things start to break down. e.g. the counterparty diff --git a/tests/test_lnchannel.py b/tests/test_lnchannel.py index 629904299..149af3748 100644 --- a/tests/test_lnchannel.py +++ b/tests/test_lnchannel.py @@ -23,11 +23,13 @@ # (around commit 42de4400bff5105352d0552155f73589166d162b). import unittest +from unittest import mock import os import binascii from pprint import pformat import logging import dataclasses +import time from electrum import bitcoin from electrum import lnpeer @@ -697,6 +699,46 @@ class TestChannel(ElectrumTestCase): self.alice_channel.add_htlc(new) self.assertIn('Not enough local balance', cm.exception.args[0]) + def test_unfunded_channel_can_be_removed(self): + """ + Test that an incoming channel which stays unfunded longer than + lnutil.CHANNEL_OPENING_TIMEOUT_BLOCKS and lnutil.CHANNEL_OPENING_TIMEOUT_SEC + can be removed + """ + # set the init_height and init_timestamp + self.current_height = 800_000 + self.bob_channel.storage['init_height'] = self.current_height + self.alice_channel.storage['init_height'] = self.current_height + self.bob_channel.storage['init_timestamp'] = int(time.time()) + self.alice_channel.storage['init_timestamp'] = int(time.time()) + + mock_lnworker = mock.Mock() + mock_blockchain = mock.Mock() + mock_lnworker.wallet = mock.Mock() + mock_lnworker.wallet.is_up_to_date = lambda: True + mock_blockchain.is_tip_stale = lambda: False + mock_lnworker.network.blockchain = lambda: mock_blockchain + mock_lnworker.network.get_local_height = lambda: self.current_height + self.bob_channel.lnworker = mock_lnworker + self.alice_channel.lnworker = mock_lnworker + + # test that the non-initiator can remove the channel after timeout + self.assertFalse(self.bob_channel.is_initiator()) + self.bob_channel._state = ChannelState.OPENING + self.assertFalse(self.bob_channel.can_be_deleted()) + self.current_height += lnutil.CHANNEL_OPENING_TIMEOUT_BLOCKS + 1 + self.assertFalse(self.bob_channel.can_be_deleted()) # needs both block and time based timeout + self.bob_channel.storage['init_timestamp'] -= lnutil.CHANNEL_OPENING_TIMEOUT_SEC + 1 + self.alice_channel.storage['init_timestamp'] -= lnutil.CHANNEL_OPENING_TIMEOUT_SEC + 1 + self.assertTrue(self.bob_channel.can_be_deleted()) # now both timeouts are reached + self.current_height = 800_000 # reset to check if we can delete with just the time based timeout + self.assertFalse(self.bob_channel.can_be_deleted()) + + # test that the initiator can't remove the channel, even after timeout + self.current_height += lnutil.CHANNEL_OPENING_TIMEOUT_BLOCKS + 1 + self.assertTrue(self.alice_channel.is_initiator()) + self.alice_channel._state = ChannelState.OPENING + self.assertFalse(self.alice_channel.can_be_deleted()) class TestChannelAnchors(TestChannel): TEST_ANCHOR_CHANNELS = True