lnchannel: allow deleting unfunded incoming channels
What changed, and why it matters
This commit fixes a bug where Electrum could not clean up incoming Lightning channels that never received their on-chain funding transaction. Previously, an internal safety check (assert) blocked deletion, causing repeated crashes in the background watcher. The patch adds proper timeout logic so these stale, unfunded channels can be removed safely. It is a reliability/DoS fix rather than a direct theft-of-funds vulnerability.
Reviewers should confirm that the dual-timeout and stale-tip guards are sufficient to prevent premature deletion of channels that are merely slow to confirm, and that the new init_height field is correctly persisted and migrated for existing channels.
Security signals we found
Fixes AssertionError crash in lnwatcher background task
Adds dual timeout gating (blocks + seconds) for deleting unfunded incoming channels
Prevents deletion when blockchain tip is stale or wallet is not synced
Restricts deletion to non-initiator, unfunded channels
Adds regression test for unfunded channel removal
Evidence from the diff
The change introduces has_funding_timed_out() on AbstractChannel and implements it for Channel/ChannelBackup. An incoming (non-initiator), unfunded channel can now be deleted once both a block-based timeout (2016 blocks) and a time-based timeout (2 weeks) have elapsed, provided the wallet is up-to-date and the blockchain tip is not stale. The timeout constants replace the old single CHANNEL_OPENING_TIMEOUT (24 hours). The patch also records init_height when a channel is created and relaxes can_be_deleted() to allow timed-out unfunded channels. A regression test verifies the behavior.
Changed components
electrum/lnchannel.pyelectrum/lnpeer.pyelectrum/lnutil.pytests/test_lnchannel.pyInspect captured patch +80 / −9
diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py
index f211f27..446ecf3 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 eaaddac..ca5929b 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 70256a7..50cfedb 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 6299042..149af37 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
Why this scored 28/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.