From 5f23b9a3bcedba28e44299fb99243ced6195e7b8 Mon Sep 17 00:00:00 2001 From: 0xztcc Date: Thu, 9 Apr 2026 12:18:09 +0000 Subject: [PATCH 1/3] backend support for 'close reason' qt frontent support for 'close reason' fix test --- electrum/commands.py | 1 + electrum/gui/qt/channel_details.py | 2 ++ electrum/gui/qt/channels_list.py | 4 ++++ electrum/lnchannel.py | 23 +++++++++++++++++++++++ electrum/lnpeer.py | 3 ++- electrum/lnworker.py | 3 ++- tests/test_lnpeer.py | 27 ++++++++++++++++++++++++++- 7 files changed, 60 insertions(+), 3 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index 301fc2eb903e..21c6c057d835 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -1877,6 +1877,7 @@ def _filter(chan): 'remote_reserve': chan.config[LOCAL].reserve_sat, 'local_unsettled_sent': chan.balance_tied_up_in_htlcs_by_direction(LOCAL, direction=SENT) // 1000, 'remote_unsettled_sent': chan.balance_tied_up_in_htlcs_by_direction(REMOTE, direction=SENT) // 1000, + 'close_reason': (r := chan.get_close_reason()) and r.name or None, # Harder to read, but keeps it within a single line. } for chan in wallet.lnworker.channels.values() if _filter(chan) ] diff --git a/electrum/gui/qt/channel_details.py b/electrum/gui/qt/channel_details.py index 0bc16527bcfa..b7d0bfdd4aae 100644 --- a/electrum/gui/qt/channel_details.py +++ b/electrum/gui/qt/channel_details.py @@ -191,6 +191,8 @@ def get_common_form(self, chan: AbstractChannel): if remote_scid_alias := chan.get_remote_scid_alias(): form.addRow(QLabel('Remote SCID Alias:'), SelectableLabel(str(ShortID(remote_scid_alias)))) form.addRow(QLabel(_('State') + ':'), SelectableLabel(chan.get_state_for_GUI())) + if close_reason := chan.get_close_reason_for_GUI(): + form.addRow(QLabel(_('Close reason') + ':'), SelectableLabel(close_reason)) if remote_peer_sent_error := chan.get_remote_peer_sent_error(): err_label = WWLabel(remote_peer_sent_error) # note: text is already truncated to reasonable len err_label.setTextFormat(QtCore.Qt.TextFormat.PlainText) diff --git a/electrum/gui/qt/channels_list.py b/electrum/gui/qt/channels_list.py index 02e298284ec5..996254b009ed 100644 --- a/electrum/gui/qt/channels_list.py +++ b/electrum/gui/qt/channels_list.py @@ -40,6 +40,7 @@ class Columns(MyTreeView.BaseColumnsEnum): LOCAL_BALANCE = enum.auto() REMOTE_BALANCE = enum.auto() CHANNEL_STATUS = enum.auto() + CLOSE_REASON = enum.auto() LONG_CHANID = enum.auto() headers = { @@ -51,6 +52,7 @@ class Columns(MyTreeView.BaseColumnsEnum): Columns.LOCAL_BALANCE: _('Can send'), Columns.REMOTE_BALANCE: _('Can receive'), Columns.CHANNEL_STATUS: _('Status'), + Columns.CLOSE_REASON: _('Close reason'), } filter_columns = [ @@ -58,6 +60,7 @@ class Columns(MyTreeView.BaseColumnsEnum): Columns.LONG_CHANID, Columns.NODE_ALIAS, Columns.CHANNEL_STATUS, + Columns.CLOSE_REASON, ] _default_item_bg_brush = None # type: Optional[QBrush] @@ -109,6 +112,7 @@ def format_fields(self, chan: AbstractChannel) -> Dict['ChannelsList.Columns', s self.Columns.LOCAL_BALANCE: '' if closed else labels[LOCAL], self.Columns.REMOTE_BALANCE: '' if closed else labels[REMOTE], self.Columns.CHANNEL_STATUS: status, + self.Columns.CLOSE_REASON: '' if not closed else chan.get_close_reason_for_GUI(), } def on_channel_closed(self, txid): diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py index bf9b80717780..627eae2a9682 100644 --- a/electrum/lnchannel.py +++ b/electrum/lnchannel.py @@ -102,6 +102,13 @@ class PeerState(IntEnum): BAD = 3 +class ChanCloseReason(IntEnum): + LOCAL_FORCE = 0 # we broadcast our own commitment tx + REMOTE_FORCE = 1 # remote broadcast their commitment tx + LOCAL_COOP = 2 # we initiated the mutual close + REMOTE_COOP = 3 # remote initiated the mutual close + + cs = ChannelState state_transitions = [ (cs.PREOPENING, cs.OPENING), @@ -192,6 +199,21 @@ class AbstractChannel(Logger, ABC): _state: ChannelState _who_closed: Optional[int] = None # HTLCOwner (1 or -1). 0 means "unknown" + def save_close_reason(self, reason: ChanCloseReason) -> None: + self.storage['close_reason'] = reason.name + + def get_close_reason(self) -> Optional[ChanCloseReason]: + value = self.storage.get('close_reason') + if value is None: + return None + return ChanCloseReason[value] + + def get_close_reason_for_GUI(self) -> str: + reason = self.get_close_reason() + if reason is None: + return '' + return reason.name + def set_short_channel_id(self, short_id: ShortChannelID) -> None: self.short_channel_id = short_id self.storage["short_channel_id"] = short_id @@ -319,6 +341,7 @@ def get_ctx_sweep_info(self, ctx: Transaction) -> Tuple[bool, Dict[str, MaybeSwe self.logger.info(f'we (local) force closed') elif who_closed == REMOTE: self.logger.info(f'they (remote) force closed.') + self.save_close_reason(ChanCloseReason.REMOTE_FORCE) else: self.logger.info(f'not sure who closed. maybe co-op close?') is_local_ctx = who_closed == LOCAL diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index fc5d99b8796d..59292f0da8a1 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -36,7 +36,7 @@ OnionRoutingFailure, ProcessedOnionPacket, UnsupportedOnionPacketVersion, InvalidOnionMac, InvalidOnionPubkey, OnionFailureCodeMetaFlag, OnionParsingError) -from .lnchannel import Channel, RevokeAndAck, ChannelState, PeerState, ChanCloseOption, CF_ANNOUNCE_CHANNEL +from .lnchannel import Channel, RevokeAndAck, ChannelState, ChanCloseReason, PeerState, ChanCloseOption, CF_ANNOUNCE_CHANNEL from . import lnutil from .lnutil import (Outpoint, LocalConfig, RECEIVED, UpdateAddHtlc, ChannelConfig, RemoteConfig, OnlyPubkeyKeypair, ChannelConstraints, RevocationStore, @@ -2731,6 +2731,7 @@ def choose_new_fee(our_fee, our_fee_range, their_fee, their_fee_range, their_pre self.lnworker.wallet.adb.add_transaction(closing_tx) except UnrelatedTransactionException: pass # this can happen if (~all the balance goes to REMOTE) + chan.save_close_reason(ChanCloseReason.LOCAL_COOP if is_local else ChanCloseReason.REMOTE_COOP) chan.set_state(ChannelState.CLOSING) # broadcast await self.network.try_broadcasting(closing_tx, 'closing') diff --git a/electrum/lnworker.py b/electrum/lnworker.py index e73fb5126adf..3344145d7944 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -65,7 +65,7 @@ ) from .lnpeer import Peer, LN_P2P_NETWORK_TIMEOUT from .lnaddr import lnencode, LnAddr, lndecode -from .lnchannel import Channel, AbstractChannel, ChannelState, PeerState, HTLCWithStatus, ChannelBackup +from .lnchannel import Channel, AbstractChannel, ChannelState, ChanCloseReason, PeerState, HTLCWithStatus, ChannelBackup from .lnrater import LNRater from .lnutil import ( get_compressed_pubkey_from_bech32, serialize_htlc_key, deserialize_htlc_key, PaymentFailure, generate_keypair, @@ -3502,6 +3502,7 @@ def _force_close_channel(self, chan_id: bytes) -> Transaction: # not safe to keep using the channel even if the broadcast errors (server could be lying). # Until the tx is seen in the mempool, there will be automatic rebroadcasts. chan.set_state(ChannelState.FORCE_CLOSING) + chan.save_close_reason(ChanCloseReason.LOCAL_FORCE) # Add local tx to wallet to also allow manual rebroadcasts. try: self.wallet.adb.add_transaction(tx) diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index 8669931c24fc..a4950ba9d515 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -36,7 +36,7 @@ from electrum.lntransport import LNPeerAddr from electrum.crypto import privkey_to_pubkey from electrum.lnutil import Keypair, PaymentFailure, LnFeatures, HTLCOwner, PaymentFeeBudget, RECEIVED -from electrum.lnchannel import ChannelState, PeerState, Channel +from electrum.lnchannel import ChannelState, ChanCloseReason, PeerState, Channel from electrum.lnrouter import LNPathFinder, PathEdge, LNPathInconsistent from electrum.channel_db import ChannelDB, InvalidGossipMsg from electrum.lnworker import LNWallet, NoPathFound, SentHtlcInfo, PaySession, LNPeerManager @@ -1617,6 +1617,7 @@ async def pay(): await p2.received_commitsig_event.wait() # alice closes await p1.close_channel(alice_channel.channel_id) + self.assertEqual(alice_channel.get_close_reason(), ChanCloseReason.LOCAL_COOP) gath.cancel() async def set_settle(): await asyncio.sleep(0.1) @@ -1625,6 +1626,29 @@ async def set_settle(): with self.assertRaises(asyncio.CancelledError): await gath + async def test_close_reason(self): + # Verify ChanCloseReason is set correctly on both sides for each close type. + graph = self.prepare_chans_and_peers_in_graph(self.GRAPH_DEFINITIONS['single_chan']) + p1, p2 = graph.peers.values() + w1, w2 = graph.workers.values() + alice_channel, bob_channel = graph.channels.values() + w1.network.config.TEST_SHUTDOWN_FEE = 100 + w2.network.config.TEST_SHUTDOWN_FEE = 100 + w1.network.config.TEST_SHUTDOWN_LEGACY = True + w2.network.config.TEST_SHUTDOWN_LEGACY = True + async def action(): + await util.wait_for2(p1.initialized, 1) + await util.wait_for2(p2.initialized, 1) + await p1.close_channel(alice_channel.channel_id) + # alice's side is completed, but bob need to wait to see the reason. + await asyncio.sleep(1) # FIXME: use a better wait + self.assertEqual(alice_channel.get_close_reason(), ChanCloseReason.LOCAL_COOP) + self.assertEqual(bob_channel.get_close_reason(), ChanCloseReason.REMOTE_COOP) + gath.cancel() + gath = asyncio.gather(action(), p1._message_loop(), p2._message_loop(), p1.htlc_switch(), p2.htlc_switch()) + with self.assertRaises(asyncio.CancelledError): + await gath + async def test_warning(self): graph = self.prepare_chans_and_peers_in_graph(self.GRAPH_DEFINITIONS['single_chan']) p1, p2 = graph.peers.values() @@ -1745,6 +1769,7 @@ async def test_channel_usage_after_closing(self): await w1.force_close_channel(alice_channel.channel_id) # check if a tx (commitment transaction) was broadcasted: assert w1.network.tx_queue.qsize() == 1 + self.assertEqual(alice_channel.get_close_reason(), ChanCloseReason.LOCAL_FORCE) with self.assertRaises(NoPathFound) as e: await w1.create_routes_from_invoice(lnaddr.get_amount_msat(), decoded_invoice=lnaddr) From 6c95554b374c0124387564fa18a995f4707681ec Mon Sep 17 00:00:00 2001 From: 0xztcc Date: Wed, 15 Apr 2026 14:51:24 +0000 Subject: [PATCH 2/3] Addressing @SomberNight's comments --- electrum/commands.py | 2 +- electrum/gui/qt/channels_list.py | 4 ---- electrum/lnchannel.py | 3 +++ tests/test_lnpeer.py | 6 ++++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/electrum/commands.py b/electrum/commands.py index 21c6c057d835..60f4f66699b4 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -1877,7 +1877,7 @@ def _filter(chan): 'remote_reserve': chan.config[LOCAL].reserve_sat, 'local_unsettled_sent': chan.balance_tied_up_in_htlcs_by_direction(LOCAL, direction=SENT) // 1000, 'remote_unsettled_sent': chan.balance_tied_up_in_htlcs_by_direction(REMOTE, direction=SENT) // 1000, - 'close_reason': (r := chan.get_close_reason()) and r.name or None, # Harder to read, but keeps it within a single line. + 'close_reason': (r := chan.get_close_reason()) and r.name or None, } for chan in wallet.lnworker.channels.values() if _filter(chan) ] diff --git a/electrum/gui/qt/channels_list.py b/electrum/gui/qt/channels_list.py index 996254b009ed..02e298284ec5 100644 --- a/electrum/gui/qt/channels_list.py +++ b/electrum/gui/qt/channels_list.py @@ -40,7 +40,6 @@ class Columns(MyTreeView.BaseColumnsEnum): LOCAL_BALANCE = enum.auto() REMOTE_BALANCE = enum.auto() CHANNEL_STATUS = enum.auto() - CLOSE_REASON = enum.auto() LONG_CHANID = enum.auto() headers = { @@ -52,7 +51,6 @@ class Columns(MyTreeView.BaseColumnsEnum): Columns.LOCAL_BALANCE: _('Can send'), Columns.REMOTE_BALANCE: _('Can receive'), Columns.CHANNEL_STATUS: _('Status'), - Columns.CLOSE_REASON: _('Close reason'), } filter_columns = [ @@ -60,7 +58,6 @@ class Columns(MyTreeView.BaseColumnsEnum): Columns.LONG_CHANID, Columns.NODE_ALIAS, Columns.CHANNEL_STATUS, - Columns.CLOSE_REASON, ] _default_item_bg_brush = None # type: Optional[QBrush] @@ -112,7 +109,6 @@ def format_fields(self, chan: AbstractChannel) -> Dict['ChannelsList.Columns', s self.Columns.LOCAL_BALANCE: '' if closed else labels[LOCAL], self.Columns.REMOTE_BALANCE: '' if closed else labels[REMOTE], self.Columns.CHANNEL_STATUS: status, - self.Columns.CLOSE_REASON: '' if not closed else chan.get_close_reason_for_GUI(), } def on_channel_closed(self, txid): diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py index 627eae2a9682..808a62e36561 100644 --- a/electrum/lnchannel.py +++ b/electrum/lnchannel.py @@ -102,6 +102,8 @@ class PeerState(IntEnum): BAD = 3 +# Note: these states are persisted by name (for a given channel) in the wallet file, +# so consider doing a wallet db upgrade when changing them. class ChanCloseReason(IntEnum): LOCAL_FORCE = 0 # we broadcast our own commitment tx REMOTE_FORCE = 1 # remote broadcast their commitment tx @@ -339,6 +341,7 @@ def get_ctx_sweep_info(self, ctx: Transaction) -> Tuple[bool, Dict[str, MaybeSwe self._who_closed = who_closed if who_closed == LOCAL: self.logger.info(f'we (local) force closed') + self.save_close_reason(ChanCloseReason.LOCAL_FORCE) elif who_closed == REMOTE: self.logger.info(f'they (remote) force closed.') self.save_close_reason(ChanCloseReason.REMOTE_FORCE) diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index a4950ba9d515..48aec48a6c38 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -1640,8 +1640,10 @@ async def action(): await util.wait_for2(p1.initialized, 1) await util.wait_for2(p2.initialized, 1) await p1.close_channel(alice_channel.channel_id) - # alice's side is completed, but bob need to wait to see the reason. - await asyncio.sleep(1) # FIXME: use a better wait + # alice's side is completed, but bob needs to wait to see the reason. + async with util.async_timeout(1): + while not bob_channel.is_closed_or_closing(): + await asyncio.sleep(0.01) self.assertEqual(alice_channel.get_close_reason(), ChanCloseReason.LOCAL_COOP) self.assertEqual(bob_channel.get_close_reason(), ChanCloseReason.REMOTE_COOP) gath.cancel() From 915a1ac080c431d9b5578d4822ad99cf1ffe0e63 Mon Sep 17 00:00:00 2001 From: 0xztcc Date: Wed, 13 May 2026 19:58:01 +0000 Subject: [PATCH 3/3] fix merge issue --- electrum/lnworker.py | 1 - 1 file changed, 1 deletion(-) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 15c8064cb00e..f57e4d6b2462 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -65,7 +65,6 @@ ) from .bolt11 import encode_bolt11_invoice, BOLT11Addr, decode_bolt11_invoice from .lnpeer import Peer, LN_P2P_NETWORK_TIMEOUT -from .lnaddr import lnencode, LnAddr, lndecode from .lnchannel import Channel, AbstractChannel, ChannelState, ChanCloseReason, PeerState, HTLCWithStatus, ChannelBackup from .lnrater import LNRater from .lnutil import (