diff --git a/electrum/commands.py b/electrum/commands.py index 1291e8d0659a..96010f3365aa 100644 --- a/electrum/commands.py +++ b/electrum/commands.py @@ -1880,6 +1880,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, } 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 42da866d0085..bbfa0a47013b 100644 --- a/electrum/gui/qt/channel_details.py +++ b/electrum/gui/qt/channel_details.py @@ -187,6 +187,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/lnchannel.py b/electrum/lnchannel.py index 49878dec5f54..dced7a0ef975 100644 --- a/electrum/lnchannel.py +++ b/electrum/lnchannel.py @@ -102,6 +102,15 @@ 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 + 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 +201,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 @@ -318,8 +342,10 @@ 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) 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 d4551cc5cc7d..d75249affdce 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, LnFeatureContexts, RemoteConfig, OnlyPubkeyKeypair, ChannelConstraints, RevocationStore, @@ -2759,6 +2759,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 92ae704a5ad8..f57e4d6b2462 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -63,9 +63,9 @@ LNTransport, LNResponderTransport, LNTransportBase, LNPeerAddr, split_host_port, extract_nodeid, ConnStringFormatError ) -from .lnpeer import Peer, LN_P2P_NETWORK_TIMEOUT from .bolt11 import encode_bolt11_invoice, BOLT11Addr, decode_bolt11_invoice -from .lnchannel import Channel, AbstractChannel, ChannelState, PeerState, HTLCWithStatus, ChannelBackup +from .lnpeer import Peer, LN_P2P_NETWORK_TIMEOUT +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, @@ -3596,6 +3596,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 9d3dacfb3c47..6e44a7abf3f3 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -37,7 +37,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 @@ -1758,6 +1758,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) @@ -1766,6 +1767,31 @@ 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 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() + 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() @@ -1886,6 +1912,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)