Browse Source

Merge pull request #7847 from SomberNight/202206_lnchan_add_toxic_state

lnchannel: add new states: `WE_ARE_TOXIC`, `REQUESTED_FCLOSE`
master
ghost43 4 years ago committed by GitHub
parent
commit
0fa28eb3d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      electrum/gui/kivy/uix/dialogs/lightning_channels.py
  2. 14
      electrum/gui/qt/channels_list.py
  3. 59
      electrum/lnchannel.py
  4. 17
      electrum/lnpeer.py
  5. 4
      electrum/lnworker.py

9
electrum/gui/kivy/uix/dialogs/lightning_channels.py

@ -8,7 +8,7 @@ from kivy.uix.popup import Popup
from electrum.util import bh2u from electrum.util import bh2u
from electrum.logging import Logger from electrum.logging import Logger
from electrum.lnutil import LOCAL, REMOTE, format_short_channel_id from electrum.lnutil import LOCAL, REMOTE, format_short_channel_id
from electrum.lnchannel import AbstractChannel, Channel, ChannelState from electrum.lnchannel import AbstractChannel, Channel, ChannelState, ChanCloseOption
from electrum.gui.kivy.i18n import _ from electrum.gui.kivy.i18n import _
from electrum.transaction import PartialTxOutput, Transaction from electrum.transaction import PartialTxOutput, Transaction
from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, format_fee_satoshis, quantize_feerate from electrum.util import NotEnoughFunds, NoDynamicFeeEstimates, format_fee_satoshis, quantize_feerate
@ -495,8 +495,8 @@ class ChannelDetailsPopup(Popup, Logger):
action_dropdown = self.ids.action_dropdown # type: ActionDropdown action_dropdown = self.ids.action_dropdown # type: ActionDropdown
options = [ options = [
ActionButtonOption(text=_('Backup'), func=lambda btn: self.export_backup()), ActionButtonOption(text=_('Backup'), func=lambda btn: self.export_backup()),
ActionButtonOption(text=_('Close channel'), func=lambda btn: self.close(), enabled=not self.is_closed), ActionButtonOption(text=_('Close channel'), func=lambda btn: self.close(), enabled=ChanCloseOption.COOP_CLOSE in self.chan.get_close_options()),
ActionButtonOption(text=_('Force-close'), func=lambda btn: self.force_close(), enabled=not self.is_closed), ActionButtonOption(text=_('Force-close'), func=lambda btn: self.force_close(), enabled=ChanCloseOption.LOCAL_FCLOSE in self.chan.get_close_options()),
ActionButtonOption(text=_('Delete'), func=lambda btn: self.remove_channel(), enabled=self.can_be_deleted), ActionButtonOption(text=_('Delete'), func=lambda btn: self.remove_channel(), enabled=self.can_be_deleted),
] ]
if not self.chan.is_closed(): if not self.chan.is_closed():
@ -557,7 +557,8 @@ class ChannelDetailsPopup(Popup, Logger):
self.app.qr_dialog(_("Channel Backup " + self.chan.short_id_for_GUI()), text, help_text=help_text) self.app.qr_dialog(_("Channel Backup " + self.chan.short_id_for_GUI()), text, help_text=help_text)
def force_close(self): def force_close(self):
if self.chan.is_closed(): if ChanCloseOption.LOCAL_FCLOSE not in self.chan.get_close_options():
# note: likely channel is already closed, or could be unsafe to do local force-close (e.g. we are toxic)
self.app.show_error(_('Channel already closed')) self.app.show_error(_('Channel already closed'))
return return
to_self_delay = self.chan.config[REMOTE].to_self_delay to_self_delay = self.chan.config[REMOTE].to_self_delay

14
electrum/gui/qt/channels_list.py

@ -13,7 +13,7 @@ from PyQt5.QtGui import QFont, QStandardItem, QBrush, QPainter, QIcon, QHelpEven
from electrum.util import bh2u, NotEnoughFunds, NoDynamicFeeEstimates from electrum.util import bh2u, NotEnoughFunds, NoDynamicFeeEstimates
from electrum.i18n import _ from electrum.i18n import _
from electrum.lnchannel import AbstractChannel, PeerState, ChannelBackup, Channel, ChannelState from electrum.lnchannel import AbstractChannel, PeerState, ChannelBackup, Channel, ChannelState, ChanCloseOption
from electrum.wallet import Abstract_Wallet from electrum.wallet import Abstract_Wallet
from electrum.lnutil import LOCAL, REMOTE, format_short_channel_id from electrum.lnutil import LOCAL, REMOTE, format_short_channel_id
from electrum.lnworker import LNWallet from electrum.lnworker import LNWallet
@ -243,8 +243,9 @@ class ChannelsList(MyTreeView):
if chan: if chan:
funding_tx = self.parent.wallet.db.get_transaction(chan.funding_outpoint.txid) funding_tx = self.parent.wallet.db.get_transaction(chan.funding_outpoint.txid)
menu.addAction(_("View funding transaction"), lambda: self.parent.show_transaction(funding_tx)) menu.addAction(_("View funding transaction"), lambda: self.parent.show_transaction(funding_tx))
if chan.get_state() == ChannelState.FUNDED: if close_opts := chan.get_close_options():
menu.addAction(_("Request force-close"), lambda: self.request_force_close(channel_id)) if ChanCloseOption.REQUEST_REMOTE_FCLOSE in close_opts:
menu.addAction(_("Request force-close"), lambda: self.request_force_close(channel_id))
if chan.can_be_deleted(): if chan.can_be_deleted():
menu.addAction(_("Delete"), lambda: self.remove_channel_backup(channel_id)) menu.addAction(_("Delete"), lambda: self.remove_channel_backup(channel_id))
menu.exec_(self.viewport().mapToGlobal(position)) menu.exec_(self.viewport().mapToGlobal(position))
@ -278,11 +279,12 @@ class ChannelsList(MyTreeView):
fm.addAction(_("Freeze for receiving"), lambda: chan.set_frozen_for_receiving(True)) fm.addAction(_("Freeze for receiving"), lambda: chan.set_frozen_for_receiving(True))
else: else:
fm.addAction(_("Unfreeze for receiving"), lambda: chan.set_frozen_for_receiving(False)) fm.addAction(_("Unfreeze for receiving"), lambda: chan.set_frozen_for_receiving(False))
if not chan.is_closed(): if close_opts := chan.get_close_options():
cm = menu.addMenu(_("Close")) cm = menu.addMenu(_("Close"))
if chan.peer_state == PeerState.GOOD: if ChanCloseOption.COOP_CLOSE in close_opts:
cm.addAction(_("Cooperative close"), lambda: self.close_channel(channel_id)) cm.addAction(_("Cooperative close"), lambda: self.close_channel(channel_id))
cm.addAction(_("Force-close"), lambda: self.force_close(channel_id)) if ChanCloseOption.LOCAL_FCLOSE in close_opts:
cm.addAction(_("Force-close"), lambda: self.force_close(channel_id))
menu.addAction(_("Export backup"), lambda: self.export_channel_backup(channel_id)) menu.addAction(_("Export backup"), lambda: self.export_channel_backup(channel_id))
if chan.can_be_deleted(): if chan.can_be_deleted():
menu.addSeparator() menu.addSeparator()

59
electrum/lnchannel.py

@ -17,12 +17,12 @@
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import enum
import os import os
from collections import namedtuple, defaultdict from collections import namedtuple, defaultdict
import binascii import binascii
import json import json
from enum import IntEnum from enum import IntEnum, Enum
from typing import (Optional, Dict, List, Tuple, NamedTuple, Set, Callable, from typing import (Optional, Dict, List, Tuple, NamedTuple, Set, Callable,
Iterable, Sequence, TYPE_CHECKING, Iterator, Union, Mapping) Iterable, Sequence, TYPE_CHECKING, Iterator, Union, Mapping)
import time import time
@ -82,11 +82,14 @@ class ChannelState(IntEnum):
OPEN = 3 # both parties have sent funding_locked OPEN = 3 # both parties have sent funding_locked
SHUTDOWN = 4 # shutdown has been sent. SHUTDOWN = 4 # shutdown has been sent.
CLOSING = 5 # closing negotiation done. we have a fully signed tx. CLOSING = 5 # closing negotiation done. we have a fully signed tx.
FORCE_CLOSING = 6 # we force-closed, and closing tx is unconfirmed. Note that if the FORCE_CLOSING = 6 # *we* force-closed, and closing tx is unconfirmed. Note that if the
# remote force-closes then we remain OPEN until it gets mined - # remote force-closes then we remain OPEN until it gets mined -
# the server could be lying to us with a fake tx. # the server could be lying to us with a fake tx.
CLOSED = 7 # closing tx has been mined REQUESTED_FCLOSE = 7 # Chan is open, but we have tried to request the *remote* to force-close
REDEEMED = 8 # we can stop watching WE_ARE_TOXIC = 8 # Chan is open, but we have lost state and the remote proved this.
# The remote must force-close, it is *not* safe for us to do so.
CLOSED = 9 # closing tx has been mined
REDEEMED = 10 # we can stop watching
class PeerState(IntEnum): class PeerState(IntEnum):
@ -113,12 +116,28 @@ state_transitions = [
(cs.OPEN, cs.FORCE_CLOSING), (cs.OPEN, cs.FORCE_CLOSING),
(cs.SHUTDOWN, cs.FORCE_CLOSING), (cs.SHUTDOWN, cs.FORCE_CLOSING),
(cs.CLOSING, cs.FORCE_CLOSING), (cs.CLOSING, cs.FORCE_CLOSING),
(cs.REQUESTED_FCLOSE, cs.FORCE_CLOSING),
# we can request a force-close almost any time
(cs.OPENING, cs.REQUESTED_FCLOSE),
(cs.FUNDED, cs.REQUESTED_FCLOSE),
(cs.OPEN, cs.REQUESTED_FCLOSE),
(cs.SHUTDOWN, cs.REQUESTED_FCLOSE),
(cs.CLOSING, cs.REQUESTED_FCLOSE),
(cs.REQUESTED_FCLOSE, cs.REQUESTED_FCLOSE),
# we can get force closed almost any time # we can get force closed almost any time
(cs.OPENING, cs.CLOSED), (cs.OPENING, cs.CLOSED),
(cs.FUNDED, cs.CLOSED), (cs.FUNDED, cs.CLOSED),
(cs.OPEN, cs.CLOSED), (cs.OPEN, cs.CLOSED),
(cs.SHUTDOWN, cs.CLOSED), (cs.SHUTDOWN, cs.CLOSED),
(cs.CLOSING, cs.CLOSED), (cs.CLOSING, cs.CLOSED),
(cs.REQUESTED_FCLOSE, cs.CLOSED),
(cs.WE_ARE_TOXIC, cs.CLOSED),
# during channel_reestablish, we might realise we have lost state
(cs.OPENING, cs.WE_ARE_TOXIC),
(cs.FUNDED, cs.WE_ARE_TOXIC),
(cs.OPEN, cs.WE_ARE_TOXIC),
(cs.SHUTDOWN, cs.WE_ARE_TOXIC),
(cs.REQUESTED_FCLOSE, cs.WE_ARE_TOXIC),
# #
(cs.FORCE_CLOSING, cs.FORCE_CLOSING), # allow multiple attempts (cs.FORCE_CLOSING, cs.FORCE_CLOSING), # allow multiple attempts
(cs.FORCE_CLOSING, cs.CLOSED), (cs.FORCE_CLOSING, cs.CLOSED),
@ -130,6 +149,12 @@ state_transitions = [
del cs # delete as name is ambiguous without context del cs # delete as name is ambiguous without context
class ChanCloseOption(Enum):
COOP_CLOSE = enum.auto()
LOCAL_FCLOSE = enum.auto()
REQUEST_REMOTE_FCLOSE = enum.auto()
class RevokeAndAck(NamedTuple): class RevokeAndAck(NamedTuple):
per_commitment_secret: bytes per_commitment_secret: bytes
next_per_commitment_point: bytes next_per_commitment_point: bytes
@ -203,6 +228,10 @@ class AbstractChannel(Logger, ABC):
def is_redeemed(self): def is_redeemed(self):
return self.get_state() == ChannelState.REDEEMED return self.get_state() == ChannelState.REDEEMED
@abstractmethod
def get_close_options(self) -> Sequence[ChanCloseOption]:
pass
def save_funding_height(self, *, txid: str, height: int, timestamp: Optional[int]) -> None: def save_funding_height(self, *, txid: str, height: int, timestamp: Optional[int]) -> None:
self.storage['funding_height'] = txid, height, timestamp self.storage['funding_height'] = txid, height, timestamp
@ -545,6 +574,12 @@ class ChannelBackup(AbstractChannel):
return self.lnworker.node_keypair.pubkey return self.lnworker.node_keypair.pubkey
raise NotImplementedError(f"unexpected cb type: {type(cb)}") raise NotImplementedError(f"unexpected cb type: {type(cb)}")
def get_close_options(self) -> Sequence[ChanCloseOption]:
ret = []
if self.get_state() == ChannelState.FUNDED:
ret.append(ChanCloseOption.REQUEST_REMOTE_FCLOSE)
return ret
class Channel(AbstractChannel): class Channel(AbstractChannel):
# note: try to avoid naming ctns/ctxs/etc as "current" and "pending". # note: try to avoid naming ctns/ctxs/etc as "current" and "pending".
@ -886,7 +921,9 @@ class Channel(AbstractChannel):
return True return True
def should_try_to_reestablish_peer(self) -> bool: def should_try_to_reestablish_peer(self) -> bool:
return ChannelState.PREOPENING < self._state < ChannelState.CLOSING and self.peer_state == PeerState.DISCONNECTED if self.peer_state != PeerState.DISCONNECTED:
return False
return ChannelState.PREOPENING < self._state < ChannelState.CLOSING
def get_funding_address(self): def get_funding_address(self):
script = funding_output_script(self.config[LOCAL], self.config[REMOTE]) script = funding_output_script(self.config[LOCAL], self.config[REMOTE])
@ -1497,6 +1534,16 @@ class Channel(AbstractChannel):
assert tx.is_complete() assert tx.is_complete()
return tx return tx
def get_close_options(self) -> Sequence[ChanCloseOption]:
ret = []
if not self.is_closed() and self.peer_state == PeerState.GOOD:
ret.append(ChanCloseOption.COOP_CLOSE)
ret.append(ChanCloseOption.REQUEST_REMOTE_FCLOSE)
if not self.is_closed() or self.get_state() == ChannelState.REQUESTED_FCLOSE:
ret.append(ChanCloseOption.LOCAL_FCLOSE)
assert not (self.get_state() == ChannelState.WE_ARE_TOXIC and ChanCloseOption.LOCAL_FCLOSE in ret), "local force-close unsafe if we are toxic"
return ret
def maybe_sweep_revoked_htlc(self, ctx: Transaction, htlc_tx: Transaction) -> Optional[SweepInfo]: def maybe_sweep_revoked_htlc(self, ctx: Transaction, htlc_tx: Transaction) -> Optional[SweepInfo]:
# look at the output address, check if it matches # look at the output address, check if it matches
return create_sweeptx_for_their_revoked_htlc(self, ctx, htlc_tx, self.sweep_address) return create_sweeptx_for_their_revoked_htlc(self, ctx, htlc_tx, self.sweep_address)

17
electrum/lnpeer.py

@ -31,7 +31,7 @@ from .lnonion import (new_onion_packet, OnionFailureCode, calc_hops_data_for_pay
process_onion_packet, OnionPacket, construct_onion_error, OnionRoutingFailure, process_onion_packet, OnionPacket, construct_onion_error, OnionRoutingFailure,
ProcessedOnionPacket, UnsupportedOnionPacketVersion, InvalidOnionMac, InvalidOnionPubkey, ProcessedOnionPacket, UnsupportedOnionPacketVersion, InvalidOnionMac, InvalidOnionPubkey,
OnionFailureCodeMetaFlag) OnionFailureCodeMetaFlag)
from .lnchannel import Channel, RevokeAndAck, RemoteCtnTooFarInFuture, ChannelState, PeerState from .lnchannel import Channel, RevokeAndAck, RemoteCtnTooFarInFuture, ChannelState, PeerState, ChanCloseOption
from . import lnutil from . import lnutil
from .lnutil import (Outpoint, LocalConfig, RECEIVED, UpdateAddHtlc, ChannelConfig, from .lnutil import (Outpoint, LocalConfig, RECEIVED, UpdateAddHtlc, ChannelConfig,
RemoteConfig, OnlyPubkeyKeypair, ChannelConstraints, RevocationStore, RemoteConfig, OnlyPubkeyKeypair, ChannelConstraints, RevocationStore,
@ -1049,7 +1049,8 @@ class Peer(Logger):
chan.set_state(ChannelState.OPENING) chan.set_state(ChannelState.OPENING)
self.lnworker.add_new_channel(chan) self.lnworker.add_new_channel(chan)
async def trigger_force_close(self, channel_id: bytes): async def request_force_close(self, channel_id: bytes):
"""Try to trigger the remote peer to force-close."""
await self.initialized await self.initialized
# First, we intentionally send a "channel_reestablish" msg with an old state. # First, we intentionally send a "channel_reestablish" msg with an old state.
# Many nodes (but not all) automatically force-close when seeing this. # Many nodes (but not all) automatically force-close when seeing this.
@ -1075,10 +1076,14 @@ class Peer(Logger):
channels_with_peer.extend(self.temp_id_to_id.values()) channels_with_peer.extend(self.temp_id_to_id.values())
if channel_id not in channels_with_peer: if channel_id not in channels_with_peer:
raise ValueError(f"channel {channel_id.hex()} does not belong to this peer") raise ValueError(f"channel {channel_id.hex()} does not belong to this peer")
if channel_id in self.channels: chan = self.channels.get(channel_id)
if not chan:
self.logger.warning(f"tried to force-close channel {channel_id.hex()} but it is not in self.channels yet")
if ChanCloseOption.LOCAL_FCLOSE in chan.get_close_options():
self.lnworker.schedule_force_closing(channel_id) self.lnworker.schedule_force_closing(channel_id)
else: else:
self.logger.warning(f"tried to force-close channel {channel_id.hex()} but it is not in self.channels yet") self.logger.info(f"tried to force-close channel {chan.get_id_for_log()} "
f"but close option is not allowed. {chan.get_state()=!r}")
def on_channel_reestablish(self, chan, msg): def on_channel_reestablish(self, chan, msg):
their_next_local_ctn = msg["next_commitment_number"] their_next_local_ctn = msg["next_commitment_number"]
@ -1171,6 +1176,7 @@ class Peer(Logger):
f"remote is ahead of us! They should force-close. Remote PCP: {bh2u(their_local_pcp)}") f"remote is ahead of us! They should force-close. Remote PCP: {bh2u(their_local_pcp)}")
# data_loss_protect_remote_pcp is used in lnsweep # data_loss_protect_remote_pcp is used in lnsweep
chan.set_data_loss_protect_remote_pcp(their_next_local_ctn - 1, their_local_pcp) chan.set_data_loss_protect_remote_pcp(their_next_local_ctn - 1, their_local_pcp)
chan.set_state(ChannelState.WE_ARE_TOXIC)
self.lnworker.save_channel(chan) self.lnworker.save_channel(chan)
chan.peer_state = PeerState.BAD chan.peer_state = PeerState.BAD
# raise after we send channel_reestablish, so the remote can realize they are ahead # raise after we send channel_reestablish, so the remote can realize they are ahead
@ -1187,7 +1193,8 @@ class Peer(Logger):
await self.initialized await self.initialized
chan_id = chan.channel_id chan_id = chan.channel_id
if chan.should_request_force_close: if chan.should_request_force_close:
await self.trigger_force_close(chan_id) chan.set_state(ChannelState.REQUESTED_FCLOSE)
await self.request_force_close(chan_id)
chan.should_request_force_close = False chan.should_request_force_close = False
return return
assert ChannelState.PREOPENING < chan.get_state() < ChannelState.FORCE_CLOSING assert ChannelState.PREOPENING < chan.get_state() < ChannelState.FORCE_CLOSING

4
electrum/lnworker.py

@ -2440,7 +2440,7 @@ class LNWallet(LNWorker):
peer.close_and_cleanup() peer.close_and_cleanup()
elif connect_str: elif connect_str:
peer = await self.add_peer(connect_str) peer = await self.add_peer(connect_str)
await peer.trigger_force_close(channel_id) await peer.request_force_close(channel_id)
elif channel_id in self.channel_backups: elif channel_id in self.channel_backups:
await self._request_force_close_from_backup(channel_id) await self._request_force_close_from_backup(channel_id)
else: else:
@ -2516,7 +2516,7 @@ class LNWallet(LNWorker):
try: try:
async with OldTaskGroup(wait=any) as group: async with OldTaskGroup(wait=any) as group:
await group.spawn(peer._message_loop()) await group.spawn(peer._message_loop())
await group.spawn(peer.trigger_force_close(channel_id)) await group.spawn(peer.request_force_close(channel_id))
return return
except Exception as e: except Exception as e:
self.logger.info(f'failed to connect {host} {e}') self.logger.info(f'failed to connect {host} {e}')

Loading…
Cancel
Save