1
0

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
```
This commit is contained in:
f321x
2025-12-05 14:42:08 +01:00
parent 6d1e8e8619
commit c34efce984
4 changed files with 80 additions and 9 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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