diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py index 6039dcdbe..c5bd9fc32 100644 --- a/electrum/lnchannel.py +++ b/electrum/lnchannel.py @@ -55,7 +55,7 @@ from .lnutil import (Outpoint, LocalConfig, RemoteConfig, Keypair, OnlyPubkeyKey ShortChannelID, map_htlcs_to_ctx_output_idxs, fee_for_htlc_output, offered_htlc_trim_threshold_sat, received_htlc_trim_threshold_sat, make_commitment_output_to_remote_address, FIXED_ANCHOR_SAT, - ChannelType, LNProtocolWarning) + ChannelType, LNProtocolWarning, ZEROCONF_TIMEOUT) from .lnsweep import sweep_our_ctx, sweep_their_ctx from .lnsweep import sweep_their_htlctx_justice, sweep_our_htlctx, SweepInfo from .lnsweep import sweep_their_ctx_to_remote_backup @@ -345,7 +345,11 @@ class AbstractChannel(Logger, ABC): def update_unfunded_state(self) -> None: self.delete_funding_height() self.delete_closing_height() - if self.get_state() in [ChannelState.PREOPENING, ChannelState.OPENING, ChannelState.FORCE_CLOSING] and self.lnworker: + 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(): # set channel state to REDEEMED so that it can be removed manually # to protect ourselves against a server lying by omission, @@ -365,8 +369,28 @@ class AbstractChannel(Logger, ABC): self.set_state(ChannelState.REDEEMED) break else: - if self.lnworker and (now() - self.storage.get('init_timestamp', 0) > CHANNEL_OPENING_TIMEOUT): + if chan_age > CHANNEL_OPENING_TIMEOUT: 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" + # 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 + # offline before seeing the funding tx + if state != ChannelState.OPEN or chan_age > ZEROCONF_TIMEOUT and self.lnworker.network.is_connected(): + # we delete the channel if its in closing state (either initiated manually by client or by LSP on failure) + # or if the channel is not seeing any funding tx after 10 minutes to prevent further usage (limit damage) + self.set_state(ChannelState.REDEEMED, force=True) + local_balance_sat = int(self.balance(LOCAL) // 1000) + if local_balance_sat > 0: + self.logger.warning( + f"we may have been scammed out of {local_balance_sat} sat by our " + f"JIT provider: {self.lnworker.config.ZEROCONF_TRUSTED_NODE} or he didn't use our preimage") + self.lnworker.config.ZEROCONF_TRUSTED_NODE = '' + self.lnworker.lnwatcher.unwatch_channel(self.get_funding_address(), self.funding_outpoint.to_str()) + # remove remaining local transactions from the wallet, this will also remove child transactions (closing tx) + self.lnworker.lnwatcher.adb.remove_transaction(self.funding_outpoint.txid) + self.lnworker.remove_channel(self.channel_id) def update_funded_state(self, *, funding_txid: str, funding_height: TxMinedInfo) -> None: self.save_funding_height(txid=funding_txid, height=funding_height.height, timestamp=funding_height.timestamp) @@ -374,6 +398,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)) + if self.is_zeroconf(): + # remove zeroconf flag as we are now confirmed, this is to prevent an electrum server causing + # us to remove a channel later in update_unfunded_state by omitting its funding tx + self.remove_zeroconf_flag() if self.get_state() == ChannelState.OPENING: if self.is_funding_tx_mined(funding_height): self.set_state(ChannelState.FUNDED) @@ -411,6 +439,14 @@ class AbstractChannel(Logger, ABC): def is_public(self) -> bool: pass + @abstractmethod + def is_zeroconf(self) -> bool: + pass + + @abstractmethod + def remove_zeroconf_flag(self) -> None: + pass + @abstractmethod def is_funding_tx_mined(self, funding_height: TxMinedInfo) -> bool: pass @@ -664,6 +700,12 @@ class ChannelBackup(AbstractChannel): def has_anchors(self) -> Optional[bool]: return None + def is_zeroconf(self) -> bool: + return False + + def remove_zeroconf_flag(self) -> None: + pass + def get_local_pubkey(self) -> bytes: cb = self.cb assert isinstance(cb, ChannelBackupStorage) @@ -906,6 +948,12 @@ class Channel(AbstractChannel): channel_type = ChannelType(self.storage.get('channel_type')) return bool(channel_type & ChannelType.OPTION_ZEROCONF) + def remove_zeroconf_flag(self) -> None: + if not self.is_zeroconf(): + return + channel_type = ChannelType(self.storage.get('channel_type')) + self.storage['channel_type'] = channel_type & ~ChannelType.OPTION_ZEROCONF + def get_sweep_address(self) -> str: # TODO: in case of unilateral close with pending HTLCs, this address will be reused if self.has_anchors(): diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 54be2629c..856d10e24 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -1161,6 +1161,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_timestamp'] = int(time.time()) if isinstance(self.transport, LNTransport): chan.add_or_update_peer_addr(self.transport.peer_addr) sig_64, _ = chan.sign_next_commitment() diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 3caa208c1..57a1503f6 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -503,6 +503,9 @@ NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE = 28 * 144 MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED = 2016 +# timeout after which we consider a zeroconf channel without funding tx to be failed +ZEROCONF_TIMEOUT = 60 * 10 + class RevocationStore: # closely based on code in lightningnetwork/lnd diff --git a/electrum/wallet.py b/electrum/wallet.py index 240fd031c..e2df92148 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -610,7 +610,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): def on_event_adb_removed_tx(self, adb, txid: str, tx: Transaction): if self.adb != adb: return - if not self.tx_is_related(tx): + if not tx or not self.tx_is_related(tx): return self.clear_tx_parents_cache() util.trigger_callback('removed_transaction', self, tx)