From 7e1fd008f032ca56a3727e1a225e6f2a2f95ad82 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Thu, 20 Nov 2025 17:03:05 +0000 Subject: [PATCH 1/2] lnsweep: lnwatcher needs to keep_watching if htlc in dont_settle_htlcs If RHASH is in lnworker.dont_settle_htlcs, we should not reveal the preimage. But also, we should not disregard the htlc either. E.g. during a JIT channel open, payment going A->B->C, C would release the preimage to B (lsp) to cover the costs of the JIT channel-open. If the upstream A->B channel gets force-closed, B should only pull the HTLC's funds if he is sure he can forward them to C. lnwatcher needs to keep watching (i.e. wait) until the RHASH gets removed from lnworker.dont_settle_htlcs, or until the CLTV of the HTLC expires. --- electrum/lnchannel.py | 8 ++++---- electrum/lnsweep.py | 45 ++++++++++++++++++++++++++++++++----------- electrum/lnwatcher.py | 8 ++++++-- 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py index 446ecf3be..df34e8c8b 100644 --- a/electrum/lnchannel.py +++ b/electrum/lnchannel.py @@ -55,7 +55,7 @@ from .lnutil import (Outpoint, LocalConfig, RemoteConfig, Keypair, OnlyPubkeyKey received_htlc_trim_threshold_sat, make_commitment_output_to_remote_address, FIXED_ANCHOR_SAT, 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_htlctx_justice, sweep_our_htlctx, SweepInfo, MaybeSweepInfo from .lnsweep import sweep_their_ctx_to_remote_backup from .lnhtlc import HTLCManager from .lnmsg import encode_msg, decode_msg @@ -286,10 +286,10 @@ class AbstractChannel(Logger, ABC): def delete_closing_height(self): self.storage.pop('closing_height', None) - def create_sweeptxs_for_our_ctx(self, ctx: Transaction) -> Dict[str, SweepInfo]: + def create_sweeptxs_for_our_ctx(self, ctx: Transaction) -> Dict[str, MaybeSweepInfo]: return sweep_our_ctx(chan=self, ctx=ctx) - def create_sweeptxs_for_their_ctx(self, ctx: Transaction) -> Dict[str, SweepInfo]: + def create_sweeptxs_for_their_ctx(self, ctx: Transaction) -> Dict[str, MaybeSweepInfo]: return sweep_their_ctx(chan=self, ctx=ctx) def is_backup(self) -> bool: @@ -304,7 +304,7 @@ class AbstractChannel(Logger, ABC): def get_remote_peer_sent_error(self) -> Optional[str]: return None - def get_ctx_sweep_info(self, ctx: Transaction) -> Tuple[bool, Dict[str, SweepInfo]]: + def get_ctx_sweep_info(self, ctx: Transaction) -> Tuple[bool, Dict[str, MaybeSweepInfo]]: our_sweep_info = self.create_sweeptxs_for_our_ctx(ctx) their_sweep_info = self.create_sweeptxs_for_their_ctx(ctx) if our_sweep_info: diff --git a/electrum/lnsweep.py b/electrum/lnsweep.py index 3e1c15210..761f7061e 100644 --- a/electrum/lnsweep.py +++ b/electrum/lnsweep.py @@ -54,6 +54,15 @@ class SweepInfo(NamedTuple): return self.txin.get_block_based_relative_locktime() or 0 +class KeepWatchingTXO(NamedTuple): + """Used for UTXOs we don't yet know if we want to sweep, such as pending HTLCs for JIT channels.""" + name: str + until_height: int + + +MaybeSweepInfo = SweepInfo | KeepWatchingTXO + + def sweep_their_ctx_watchtower( chan: 'Channel', ctx: Transaction, @@ -282,7 +291,7 @@ def sweep_our_ctx( *, chan: 'AbstractChannel', ctx: Transaction, actual_htlc_tx: Transaction=None, # if passed, return second stage htlcs -) -> Dict[str, SweepInfo]: +) -> Dict[str, MaybeSweepInfo]: """Handle the case where we force-close unilaterally with our latest ctx. @@ -329,7 +338,7 @@ def sweep_our_ctx( # other outputs are htlcs # if they are spent, we need to generate the script # so, second-stage htlc sweep should not be returned here - txs = {} # type: Dict[str, SweepInfo] + txs = {} # type: Dict[str, MaybeSweepInfo] # local anchor if actual_htlc_tx is None and chan.has_anchors(): @@ -444,9 +453,13 @@ def sweep_our_ctx( # note: it is the first stage (witness of htlc_tx) that reveals the preimage, # so if we are already in second stage, it is already revealed. # However, here, we don't make a distinction. - preimage = _maybe_reveal_preimage_for_htlc( + preimage, keep_watching_txo = _maybe_reveal_preimage_for_htlc( chan=chan, htlc=htlc, + sweep_info_name=f"our_ctx_htlc_{ctx_output_idx}", ) + if keep_watching_txo: + prevout = ctx.txid() + ':%d' % ctx_output_idx + txs[prevout] = keep_watching_txo if not preimage: continue try: @@ -465,7 +478,8 @@ def _maybe_reveal_preimage_for_htlc( *, chan: 'AbstractChannel', htlc: 'UpdateAddHtlc', -) -> Optional[bytes]: + sweep_info_name: str, +) -> Tuple[Optional[bytes], Optional[KeepWatchingTXO]]: """Given a Remote-added-HTLC, return the preimage if it's okay to reveal it on-chain.""" if not chan.lnworker.is_complete_mpp(htlc.payment_hash): # - do not redeem this, it might publish the preimage of an incomplete MPP @@ -473,11 +487,16 @@ def _maybe_reveal_preimage_for_htlc( # for this MPP set. So the MPP set might still transition to complete! # The MPP_TIMEOUT is only around 2 minutes, so this window is short. # The default keep_watching logic in lnwatcher is sufficient to call us again. - return None - if htlc.payment_hash in chan.lnworker.dont_settle_htlcs: - return None + return None, None + if htlc.payment_hash.hex() in chan.lnworker.dont_settle_htlcs: + # we should not reveal the preimage *for now*, but we might still decide to reveal it later + keep_watching_txo = KeepWatchingTXO( + name=sweep_info_name + "_dont_settle_htlcs", + until_height=htlc.cltv_abs, + ) + return None, keep_watching_txo preimage = chan.lnworker.get_preimage(htlc.payment_hash) - return preimage + return preimage, None def extract_ctx_secrets(chan: 'Channel', ctx: Transaction): @@ -614,7 +633,7 @@ def sweep_their_ctx_to_remote_backup( def sweep_their_ctx( *, chan: 'Channel', - ctx: Transaction) -> Optional[Dict[str, SweepInfo]]: + ctx: Transaction) -> Optional[Dict[str, MaybeSweepInfo]]: """Handle the case when the remote force-closes with their ctx. Sweep outputs that do not have a CSV delay ('to_remote' and first-stage HTLCs). Outputs with CSV delay ('to_local' and second-stage HTLCs) are redeemed by LNWatcher. @@ -628,7 +647,7 @@ def sweep_their_ctx( Outputs with CSV/CLTV are redeemed by LNWatcher. """ - txs = {} # type: Dict[str, SweepInfo] + txs = {} # type: Dict[str, MaybeSweepInfo] our_conf, their_conf = get_ordered_channel_configs(chan=chan, for_us=True) x = extract_ctx_secrets(chan, ctx) if not x: @@ -761,9 +780,13 @@ def sweep_their_ctx( preimage = None is_received_htlc = direction == RECEIVED if not is_received_htlc and not is_revocation: - preimage = _maybe_reveal_preimage_for_htlc( + preimage, keep_watching_txo = _maybe_reveal_preimage_for_htlc( chan=chan, htlc=htlc, + sweep_info_name=f"their_ctx_htlc_{ctx_output_idx}", ) + if keep_watching_txo: + prevout = ctx.txid() + ':%d' % ctx_output_idx + txs[prevout] = keep_watching_txo if not preimage: continue tx_htlc( diff --git a/electrum/lnwatcher.py b/electrum/lnwatcher.py index e41497dbb..354b0a6a0 100644 --- a/electrum/lnwatcher.py +++ b/electrum/lnwatcher.py @@ -11,11 +11,10 @@ from .transaction import Transaction, TxOutpoint from .logging import Logger from .address_synchronizer import TX_HEIGHT_LOCAL from .lnutil import REDEEM_AFTER_DOUBLE_SPENT_DELAY - +from .lnsweep import KeepWatchingTXO, SweepInfo if TYPE_CHECKING: from .network import Network - from .lnsweep import SweepInfo from .lnworker import LNWallet from .lnchannel import AbstractChannel @@ -159,6 +158,7 @@ class LNWatcher(Logger, EventListener): chan = self.lnworker.channel_by_txo(funding_outpoint) if not chan: return False + local_height = self.adb.get_local_height() # detect who closed and get information about how to claim outputs is_local_ctx, sweep_info_dict = chan.get_ctx_sweep_info(closing_tx) # note: we need to keep watching *at least* until the closing tx is deeply mined, @@ -169,6 +169,10 @@ class LNWatcher(Logger, EventListener): prev_txid, prev_index = prevout.split(':') name = sweep_info.name + ' ' + chan.get_id_for_log() self.lnworker.wallet.set_default_label(prevout, name) + if isinstance(sweep_info, KeepWatchingTXO): # haven't yet decided if we want to sweep + keep_watching |= sweep_info.until_height > local_height + continue + assert isinstance(sweep_info, SweepInfo), sweep_info if not self.adb.get_transaction(prev_txid): # do not keep watching if prevout does not exist self.logger.info(f'prevout does not exist for {name}: {prevout}') From f339a6b76dfe35fe13038b217a205d762c7848f2 Mon Sep 17 00:00:00 2001 From: SomberNight Date: Wed, 17 Dec 2025 10:23:16 +0000 Subject: [PATCH 2/2] lnwatcher: follow-up prev --- electrum/lnchannel.py | 6 +++--- electrum/lnwatcher.py | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py index df34e8c8b..683f44a62 100644 --- a/electrum/lnchannel.py +++ b/electrum/lnchannel.py @@ -327,7 +327,7 @@ class AbstractChannel(Logger, ABC): is_local_ctx = who_closed == LOCAL return is_local_ctx, sweep_info - def maybe_sweep_htlcs(self, ctx: Transaction, htlc_tx: Transaction) -> Dict[str, SweepInfo]: + def maybe_sweep_htlcs(self, ctx: Transaction, htlc_tx: Transaction) -> Dict[str, MaybeSweepInfo]: return {} def extract_preimage_from_htlc_txin(self, txin: TxInput, *, is_deeply_mined: bool) -> None: @@ -682,7 +682,7 @@ class ChannelBackup(AbstractChannel): else: return {} - def maybe_sweep_htlcs(self, ctx: Transaction, htlc_tx: Transaction) -> Dict[str, SweepInfo]: + def maybe_sweep_htlcs(self, ctx: Transaction, htlc_tx: Transaction) -> Dict[str, MaybeSweepInfo]: return {} def extract_preimage_from_htlc_txin(self, txin: TxInput, *, is_deeply_mined: bool) -> None: @@ -1939,7 +1939,7 @@ class Channel(AbstractChannel): 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_htlcs(self, ctx: Transaction, htlc_tx: Transaction) -> Dict[str, SweepInfo]: + def maybe_sweep_htlcs(self, ctx: Transaction, htlc_tx: Transaction) -> Dict[str, MaybeSweepInfo]: # look at the output address, check if it matches d = sweep_their_htlctx_justice(self, ctx, htlc_tx) d2 = sweep_our_htlctx(self, ctx, htlc_tx) diff --git a/electrum/lnwatcher.py b/electrum/lnwatcher.py index 354b0a6a0..4db9b04e8 100644 --- a/electrum/lnwatcher.py +++ b/electrum/lnwatcher.py @@ -184,9 +184,13 @@ class LNWatcher(Logger, EventListener): # the spender might be the remote, revoked or not htlc_sweepinfo = chan.maybe_sweep_htlcs(closing_tx, spender_tx) for prevout2, htlc_sweep_info in htlc_sweepinfo.items(): + self.lnworker.wallet.set_default_label(prevout2, htlc_sweep_info.name) + if isinstance(htlc_sweep_info, KeepWatchingTXO): # haven't yet decided if we want to sweep + keep_watching |= htlc_sweep_info.until_height > local_height + continue + assert isinstance(htlc_sweep_info, SweepInfo), htlc_sweep_info watch_htlc_sweep_info = self.maybe_redeem(htlc_sweep_info) htlc_tx_spender = self.adb.get_spender(prevout2) - self.lnworker.wallet.set_default_label(prevout2, htlc_sweep_info.name) if htlc_tx_spender: keep_watching |= not self.adb.is_deeply_mined(htlc_tx_spender) self.maybe_add_accounting_address(htlc_tx_spender, htlc_sweep_info)