Browse Source

Merge pull request #7847 from SomberNight/202206_lnchan_add_toxic_state

lnchannel: add new states: `WE_ARE_TOXIC`, `REQUESTED_FCLOSE`
patch-4
ghost43 3 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.logging import Logger
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.transaction import PartialTxOutput, Transaction
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
options = [
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=_('Force-close'), func=lambda btn: self.force_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=ChanCloseOption.LOCAL_FCLOSE in self.chan.get_close_options()),
ActionButtonOption(text=_('Delete'), func=lambda btn: self.remove_channel(), enabled=self.can_be_deleted),
]
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)
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'))
return
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.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.lnutil import LOCAL, REMOTE, format_short_channel_id
from electrum.lnworker import LNWallet
@ -243,8 +243,9 @@ class ChannelsList(MyTreeView):
if chan:
funding_tx = self.parent.wallet.db.get_transaction(chan.funding_outpoint.txid)
menu.addAction(_("View funding transaction"), lambda: self.parent.show_transaction(funding_tx))
if chan.get_state() == ChannelState.FUNDED:
menu.addAction(_("Request force-close"), lambda: self.request_force_close(channel_id))
if close_opts := chan.get_close_options():
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():
menu.addAction(_("Delete"), lambda: self.remove_channel_backup(channel_id))
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))
else:
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"))
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(_("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))
if chan.can_be_deleted():
menu.addSeparator()

59
electrum/lnchannel.py

@ -17,12 +17,12 @@
# 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
# THE SOFTWARE.
import enum
import os
from collections import namedtuple, defaultdict
import binascii
import json
from enum import IntEnum
from enum import IntEnum, Enum
from typing import (Optional, Dict, List, Tuple, NamedTuple, Set, Callable,
Iterable, Sequence, TYPE_CHECKING, Iterator, Union, Mapping)
import time
@ -82,11 +82,14 @@ class ChannelState(IntEnum):
OPEN = 3 # both parties have sent funding_locked
SHUTDOWN = 4 # shutdown has been sent.
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 -
# the server could be lying to us with a fake tx.
CLOSED = 7 # closing tx has been mined
REDEEMED = 8 # we can stop watching
REQUESTED_FCLOSE = 7 # Chan is open, but we have tried to request the *remote* to force-close
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):
@ -113,12 +116,28 @@ state_transitions = [
(cs.OPEN, cs.FORCE_CLOSING),
(cs.SHUTDOWN, 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
(cs.OPENING, cs.CLOSED),
(cs.FUNDED, cs.CLOSED),
(cs.OPEN, cs.CLOSED),
(cs.SHUTDOWN, 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.CLOSED),
@ -130,6 +149,12 @@ state_transitions = [
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):
per_commitment_secret: bytes
next_per_commitment_point: bytes
@ -203,6 +228,10 @@ class AbstractChannel(Logger, ABC):
def is_redeemed(self):
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:
self.storage['funding_height'] = txid, height, timestamp
@ -545,6 +574,12 @@ class ChannelBackup(AbstractChannel):
return self.lnworker.node_keypair.pubkey
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):
# note: try to avoid naming ctns/ctxs/etc as "current" and "pending".
@ -886,7 +921,9 @@ class Channel(AbstractChannel):
return True
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):
script = funding_output_script(self.config[LOCAL], self.config[REMOTE])
@ -1497,6 +1534,16 @@ class Channel(AbstractChannel):
assert tx.is_complete()
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]:
# look at the output address, check if it matches
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,
ProcessedOnionPacket, UnsupportedOnionPacketVersion, InvalidOnionMac, InvalidOnionPubkey,
OnionFailureCodeMetaFlag)
from .lnchannel import Channel, RevokeAndAck, RemoteCtnTooFarInFuture, ChannelState, PeerState
from .lnchannel import Channel, RevokeAndAck, RemoteCtnTooFarInFuture, ChannelState, PeerState, ChanCloseOption
from . import lnutil
from .lnutil import (Outpoint, LocalConfig, RECEIVED, UpdateAddHtlc, ChannelConfig,
RemoteConfig, OnlyPubkeyKeypair, ChannelConstraints, RevocationStore,
@ -1049,7 +1049,8 @@ class Peer(Logger):
chan.set_state(ChannelState.OPENING)
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
# First, we intentionally send a "channel_reestablish" msg with an old state.
# 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())
if channel_id not in channels_with_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)
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):
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)}")
# 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_state(ChannelState.WE_ARE_TOXIC)
self.lnworker.save_channel(chan)
chan.peer_state = PeerState.BAD
# raise after we send channel_reestablish, so the remote can realize they are ahead
@ -1187,7 +1193,8 @@ class Peer(Logger):
await self.initialized
chan_id = chan.channel_id
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
return
assert ChannelState.PREOPENING < chan.get_state() < ChannelState.FORCE_CLOSING

4
electrum/lnworker.py

@ -2440,7 +2440,7 @@ class LNWallet(LNWorker):
peer.close_and_cleanup()
elif 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:
await self._request_force_close_from_backup(channel_id)
else:
@ -2516,7 +2516,7 @@ class LNWallet(LNWorker):
try:
async with OldTaskGroup(wait=any) as group:
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
except Exception as e:
self.logger.info(f'failed to connect {host} {e}')

Loading…
Cancel
Save