From 798df671ea581359f5b23cbc6b3de8202d0ced23 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Tue, 11 Mar 2025 18:13:32 +0100 Subject: [PATCH] If we have proposed htlcs in a channel that was force-closed, call lnworker.htlc_failed once the htlc_tx is deeply mined. In the case of a forwarding, this will fail incoming htlcs. (fixes #8547) --- electrum/lnchannel.py | 74 ++++++++++++++++++++++++++++------------ electrum/lnwatcher.py | 20 ++++++----- electrum/lnworker.py | 10 ++++-- tests/regtest.py | 16 +++++++++ tests/regtest/regtest.sh | 37 ++++++++++++++++++++ 5 files changed, 124 insertions(+), 33 deletions(-) diff --git a/electrum/lnchannel.py b/electrum/lnchannel.py index 2562e78e3..bada10709 100644 --- a/electrum/lnchannel.py +++ b/electrum/lnchannel.py @@ -321,7 +321,7 @@ class AbstractChannel(Logger, ABC): def maybe_sweep_htlcs(self, ctx: Transaction, htlc_tx: Transaction) -> Dict[str, SweepInfo]: return {} - def extract_preimage_from_htlc_txin(self, txin: TxInput) -> None: + def extract_preimage_from_htlc_txin(self, txin: TxInput, *, is_deeply_mined: bool) -> None: return def update_onchain_state(self, *, funding_txid: str, funding_height: TxMinedInfo, @@ -658,7 +658,7 @@ class ChannelBackup(AbstractChannel): def maybe_sweep_htlcs(self, ctx: Transaction, htlc_tx: Transaction) -> Dict[str, SweepInfo]: return {} - def extract_preimage_from_htlc_txin(self, txin: TxInput) -> None: + def extract_preimage_from_htlc_txin(self, txin: TxInput, *, is_deeply_mined: bool) -> None: return None def get_funding_address(self): @@ -1350,42 +1350,72 @@ class Channel(AbstractChannel): error_bytes, failure_message = None, None self.lnworker.htlc_failed(self, htlc.payment_hash, htlc.htlc_id, error_bytes, failure_message) - def extract_preimage_from_htlc_txin(self, txin: TxInput) -> None: + + def extract_preimage_from_htlc_txin(self, txin: TxInput, *, is_deeply_mined: bool) -> None: + from . import lnutil + from .crypto import ripemd + from .transaction import match_script_against_template, script_GetOp + from .lnonion import OnionRoutingFailure, OnionFailureCode witness = txin.witness_elements() - if len(witness) == 5: # HTLC success tx - preimage = witness[3] - elif len(witness) == 3: # spending offered HTLC directly from ctx - preimage = witness[1] + witness_script = witness[-1] + script_ops = [x for x in script_GetOp(witness_script)] + if match_script_against_template(witness_script, lnutil.WITNESS_TEMPLATE_OFFERED_HTLC, debug=False) \ + or match_script_against_template(witness_script, lnutil.WITNESS_TEMPLATE_OFFERED_HTLC_ANCHORS, debug=False): + ripemd_payment_hash = script_ops[21][1] + elif match_script_against_template(witness_script, lnutil.WITNESS_TEMPLATE_RECEIVED_HTLC, debug=False) \ + or match_script_against_template(witness_script, lnutil.WITNESS_TEMPLATE_RECEIVED_HTLC_ANCHORS, debug=False): + ripemd_payment_hash = script_ops[14][1] else: return - payment_hash = sha256(preimage) found = {} for direction, htlc in itertools.chain( self.hm.get_htlcs_in_oldest_unrevoked_ctx(REMOTE), self.hm.get_htlcs_in_latest_ctx(REMOTE)): - if htlc.payment_hash == payment_hash: + if ripemd(htlc.payment_hash) == ripemd_payment_hash: is_sent = direction == RECEIVED found[htlc.htlc_id] = (htlc, is_sent) for direction, htlc in itertools.chain( self.hm.get_htlcs_in_oldest_unrevoked_ctx(LOCAL), self.hm.get_htlcs_in_latest_ctx(LOCAL)): - if htlc.payment_hash == payment_hash: + if ripemd(htlc.payment_hash) == ripemd_payment_hash: is_sent = direction == SENT found[htlc.htlc_id] = (htlc, is_sent) if not found: return - if self.lnworker.get_preimage(payment_hash) is not None: - return - self.logger.info(f"found preimage in witness of length {len(witness)}, for {payment_hash.hex()}") - # ^ note: log message text grepped for in regtests - self.lnworker.save_preimage(payment_hash, preimage) - for htlc, is_sent in found.values(): - if is_sent: - self.lnworker.htlc_fulfilled(self, payment_hash, htlc.htlc_id) - else: - # FIXME - #self.lnworker.htlc_received(self, payment_hash) - pass + if len(witness) == 5: # HTLC success tx + preimage = witness[3] + elif len(witness) == 3: # spending offered HTLC directly from ctx + preimage = witness[1] + else: + preimage = None # HTLC timeout tx + if preimage: + assert ripemd(sha256(preimage)) == ripemd_payment_hash + payment_hash = sha256(preimage) + if self.lnworker.get_preimage(payment_hash) is not None: + return + # ^ note: log message text grepped for in regtests + self.logger.info(f"found preimage in witness of length {len(witness)}, for {payment_hash.hex()}") + + if preimage: + self.lnworker.save_preimage(payment_hash, preimage) + for htlc, is_sent in found.values(): + if is_sent: + self.lnworker.htlc_fulfilled(self, payment_hash, htlc.htlc_id) + else: + # htlc timeout tx + if not is_deeply_mined: + return + failure = OnionRoutingFailure(code=OnionFailureCode.PERMANENT_CHANNEL_FAILURE, data=b'') + for htlc, is_sent in found.values(): + if is_sent: + self.logger.info(f'htlc timeout tx: failing htlc {is_sent}') + self.lnworker.htlc_failed( + self, + payment_hash=htlc.payment_hash, + htlc_id=htlc.htlc_id, + error_bytes=None, + failure_message=failure) + def balance(self, whose: HTLCOwner, *, ctx_owner=HTLCOwner.LOCAL, ctn: int = None) -> int: assert type(whose) is HTLCOwner diff --git a/electrum/lnwatcher.py b/electrum/lnwatcher.py index 737cac695..70acd7692 100644 --- a/electrum/lnwatcher.py +++ b/electrum/lnwatcher.py @@ -22,7 +22,7 @@ if TYPE_CHECKING: from .network import Network from .lnsweep import SweepInfo from .lnworker import LNWallet - + from .lnchannel import AbstractChannel class TxMinedDepth(IntEnum): """ IntEnum because we call min() in get_deepest_tx_mined_depth_for_txids """ @@ -156,7 +156,7 @@ class LNWatcher(Logger, EventListener): return TxMinedDepth.FREE tx_mined_depth = self.adb.get_tx_height(txid) height, conf = tx_mined_depth.height, tx_mined_depth.conf - if conf > 100: + if conf > 20: return TxMinedDepth.DEEP elif conf > 0: return TxMinedDepth.SHALLOW @@ -175,7 +175,6 @@ class LNWatcher(Logger, EventListener): - class LNWalletWatcher(LNWatcher): def __init__(self, lnworker: 'LNWallet', network: 'Network'): @@ -263,12 +262,8 @@ class LNWalletWatcher(LNWatcher): else: keep_watching = True await self.maybe_redeem(prevout2, htlc_sweep_info, name) - # extract preimage keep_watching |= not self.is_deeply_mined(spender_txid) - txin_idx = spender_tx.get_input_idx_that_spent_prevout(TxOutpoint.from_str(prevout)) - assert txin_idx is not None - spender_txin = spender_tx.inputs()[txin_idx] - chan.extract_preimage_from_htlc_txin(spender_txin) + self.maybe_extract_preimage(chan, spender_tx, prevout) else: keep_watching = True # broadcast or maybe update our own tx @@ -374,3 +369,12 @@ class LNWalletWatcher(LNWatcher): if old_tx and old_tx.txid() != new_tx.txid(): self.lnworker.wallet.set_label(old_tx.txid(), None) util.trigger_callback('wallet_updated', self.lnworker.wallet) + + def maybe_extract_preimage(self, chan: 'AbstractChannel', spender_tx: Transaction, prevout: str): + txin_idx = spender_tx.get_input_idx_that_spent_prevout(TxOutpoint.from_str(prevout)) + assert txin_idx is not None + spender_txin = spender_tx.inputs()[txin_idx] + chan.extract_preimage_from_htlc_txin( + spender_txin, + is_deeply_mined=self.is_deeply_mined(spender_tx.txid()), + ) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 8881846ca..329d031c8 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -2484,7 +2484,8 @@ class LNWallet(LNWorker): fw_htlcs = self.active_forwardings[fw_key] fw_htlcs.remove(htlc_key) - if shi := self.sent_htlcs_info.get((payment_hash, chan.short_channel_id, htlc_id)): + shi = self.sent_htlcs_info.get((payment_hash, chan.short_channel_id, htlc_id)) + if shi and htlc_id in chan.onion_keys: chan.pop_onion_key(htlc_id) payment_key = payment_hash + shi.payment_secret_orig paysession = self._paysessions[payment_key] @@ -2520,6 +2521,7 @@ class LNWallet(LNWorker): htlc_id: int, error_bytes: Optional[bytes], failure_message: Optional['OnionRoutingFailure']): + # note: this may be called several times for the same htlc util.trigger_callback('htlc_failed', payment_hash, chan, htlc_id) htlc_key = serialize_htlc_key(chan.get_scid_or_local_alias(), htlc_id) @@ -2528,7 +2530,8 @@ class LNWallet(LNWorker): fw_htlcs = self.active_forwardings[fw_key] fw_htlcs.remove(htlc_key) - if shi := self.sent_htlcs_info.get((payment_hash, chan.short_channel_id, htlc_id)): + shi = self.sent_htlcs_info.get((payment_hash, chan.short_channel_id, htlc_id)) + if shi and htlc_id in chan.onion_keys: onion_key = chan.pop_onion_key(htlc_id) payment_okey = payment_hash + shi.payment_secret_orig paysession = self._paysessions[payment_okey] @@ -2583,10 +2586,11 @@ class LNWallet(LNWorker): fw_htlcs = self.active_forwardings[fw_key] can_forward_failure = (len(fw_htlcs) == 0) and not paysession_active if can_forward_failure: + self.logger.info(f'htlc_failed: save_forwarding_failure (phash={payment_hash.hex()})') self.save_forwarding_failure(fw_key, error_bytes=error_bytes, failure_message=failure_message) self.notify_upstream_peer(htlc_key) else: - self.logger.info(f"waiting for other htlcs to fail (phash={payment_hash.hex()})") + self.logger.info(f'htlc_failed: waiting for other htlcs to fail (phash={payment_hash.hex()})') def calc_routing_hints_for_invoice(self, amount_msat: Optional[int], channels=None, needs_jit=False): """calculate routing hints (BOLT-11 'r' field)""" diff --git a/tests/regtest.py b/tests/regtest.py index a5fcfca10..603737923 100644 --- a/tests/regtest.py +++ b/tests/regtest.py @@ -124,6 +124,22 @@ class TestLightningWatchtower(TestLightning): self.run_shell(['watchtower']) +class TestLightningABC(TestLightning): + agents = { + 'alice': { + }, + 'bob': { + 'lightning_listen': 'localhost:9735', + 'lightning_forward_payments': 'true', + }, + 'carol': { + } + } + + def test_fw_fail_htlc(self): + self.run_shell(['fw_fail_htlc']) + + class TestLightningJIT(TestLightning): agents = { 'alice': { diff --git a/tests/regtest/regtest.sh b/tests/regtest/regtest.sh index 7ac59f751..bc455d980 100755 --- a/tests/regtest/regtest.sh +++ b/tests/regtest/regtest.sh @@ -14,6 +14,7 @@ bitcoin_cli="bitcoin-cli -rpcuser=doggman -rpcpassword=donkey -rpcport=18554 -re function new_blocks() { + printf "mining $1 blocks\n" $bitcoin_cli generatetoaddress $1 $($bitcoin_cli getnewaddress) > /dev/null } @@ -510,6 +511,42 @@ if [[ $1 == "watchtower" ]]; then wait_until_spent $ctx_id $output_index # alice's to_local gets punished fi +if [[ $1 == "fw_fail_htlc" ]]; then + $carol enable_htlc_settle false + bob_node=$($bob nodeid) + wait_for_balance carol 1 + echo "alice and carol open channels with bob" + chan_id1=$($alice open_channel $bob_node 0.15 --password='' --push_amount=0.075) + chan_id2=$($carol open_channel $bob_node 0.15 --password='' --push_amount=0.075) + new_blocks 3 + wait_until_channel_open alice + wait_until_channel_open carol + echo "alice pays carol" + invoice=$($carol add_request 0.01 --lightning -m "invoice" | jq -r ".lightning_invoice") + screen -S alice_payment -dm -L -Logfile /tmp/alice/screen1.log $alice lnpay $invoice --timeout=600 + sleep 1 + unsettled=$($alice list_channels | jq '.[] | .local_unsettled_sent') + if [[ "$unsettled" == "0" ]]; then + echo 'enable_htlc_settle did not work (carol settled)' + exit 1 + fi + $carol stop + $bob close_channel $chan_id2 --force + new_blocks 1 + sleep 1 + new_blocks 150 # cltv before bob can broadcast + sleep 5 # give bob time to process blocks and broadcast + new_blocks 1 # confirm 2nd stage. + sleep 1 + new_blocks 100 # deep + sleep 5 # give bob time to fail incoming htlc + unsettled=$($alice list_channels | jq '.[] | .local_unsettled_sent') + if [[ "$unsettled" != "0" ]]; then + echo 'alice htlc was not failed' + exit 1 + fi +fi + if [[ $1 == "just_in_time" ]]; then bob_node=$($bob nodeid) $alice setconfig zeroconf_trusted_node $bob_node