From 44f29331bfab8278e61bbcd1b6a583eb8c8b466d Mon Sep 17 00:00:00 2001 From: ThomasV Date: Thu, 28 Apr 2022 10:21:47 +0200 Subject: [PATCH] lnwatcher: in inspect_tx_candidate, match witness scripts against HTLC templates fixes #7781 --- electrum/lnutil.py | 65 ++++++++++++++++++++++++++++++++++++++++- electrum/lnwatcher.py | 47 ++++++++++++++++++++++------- electrum/transaction.py | 10 ++++++- 3 files changed, 109 insertions(+), 13 deletions(-) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 79a1f42f3..75a879872 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -24,7 +24,8 @@ from . import segwit_addr from .i18n import _ from .lnaddr import lndecode from .bip32 import BIP32Node, BIP32_PRIME -from .transaction import BCDataStream +from .transaction import BCDataStream, OPPushDataGeneric + if TYPE_CHECKING: from .lnchannel import Channel, AbstractChannel @@ -636,6 +637,68 @@ def make_received_htlc(revocation_pubkey: bytes, remote_htlcpubkey: bytes, ])) return script +WITNESS_TEMPLATE_OFFERED_HTLC = [ + opcodes.OP_DUP, + opcodes.OP_HASH160, + OPPushDataGeneric(None), + opcodes.OP_EQUAL, + opcodes.OP_IF, + opcodes.OP_CHECKSIG, + opcodes.OP_ELSE, + OPPushDataGeneric(None), + opcodes.OP_SWAP, + opcodes.OP_SIZE, + OPPushDataGeneric(lambda x: x==1), + opcodes.OP_EQUAL, + opcodes.OP_NOTIF, + opcodes.OP_DROP, + opcodes.OP_2, + opcodes.OP_SWAP, + OPPushDataGeneric(None), + opcodes.OP_2, + opcodes.OP_CHECKMULTISIG, + opcodes.OP_ELSE, + opcodes.OP_HASH160, + OPPushDataGeneric(None), + opcodes.OP_EQUALVERIFY, + opcodes.OP_CHECKSIG, + opcodes.OP_ENDIF, + opcodes.OP_ENDIF, +] + +WITNESS_TEMPLATE_RECEIVED_HTLC = [ + opcodes.OP_DUP, + opcodes.OP_HASH160, + OPPushDataGeneric(None), + opcodes.OP_EQUAL, + opcodes.OP_IF, + opcodes.OP_CHECKSIG, + opcodes.OP_ELSE, + OPPushDataGeneric(None), + opcodes.OP_SWAP, + opcodes.OP_SIZE, + OPPushDataGeneric(lambda x: x==1), + opcodes.OP_EQUAL, + opcodes.OP_IF, + opcodes.OP_HASH160, + OPPushDataGeneric(None), + opcodes.OP_EQUALVERIFY, + opcodes.OP_2, + opcodes.OP_SWAP, + OPPushDataGeneric(None), + opcodes.OP_2, + opcodes.OP_CHECKMULTISIG, + opcodes.OP_ELSE, + opcodes.OP_DROP, + OPPushDataGeneric(None), + opcodes.OP_CHECKLOCKTIMEVERIFY, + opcodes.OP_DROP, + opcodes.OP_CHECKSIG, + opcodes.OP_ENDIF, + opcodes.OP_ENDIF, +] + + def make_htlc_output_witness_script(is_received_htlc: bool, remote_revocation_pubkey: bytes, remote_htlc_pubkey: bytes, local_htlc_pubkey: bytes, payment_hash: bytes, cltv_expiry: Optional[int]) -> bytes: if is_received_htlc: diff --git a/electrum/lnwatcher.py b/electrum/lnwatcher.py index fa2aba339..1b0b53e8a 100644 --- a/electrum/lnwatcher.py +++ b/electrum/lnwatcher.py @@ -14,6 +14,9 @@ from .wallet_db import WalletDB from .util import bh2u, bfh, log_exceptions, ignore_exceptions, TxMinedInfo, random_shuffled_copy from .address_synchronizer import AddressSynchronizer, TX_HEIGHT_LOCAL, TX_HEIGHT_UNCONF_PARENT, TX_HEIGHT_UNCONFIRMED from .transaction import Transaction, TxOutpoint +from .transaction import match_script_against_template +from .lnutil import WITNESS_TEMPLATE_RECEIVED_HTLC, WITNESS_TEMPLATE_OFFERED_HTLC + if TYPE_CHECKING: from .network import Network @@ -222,24 +225,46 @@ class LNWatcher(AddressSynchronizer): raise NotImplementedError() # implemented by subclasses def inspect_tx_candidate(self, outpoint, n): + """ + returns a dict of spenders for a transaction of interest. + subscribes to addresses as a side effect. + n==0 => outpoint is a channel funding. + n==1 => outpoint is a commitment or close output: to_local, to_remote or first-stage htlc + n==2 => outpoint is a second-stage htlc + """ prev_txid, index = outpoint.split(':') - txid = self.db.get_spent_outpoint(prev_txid, int(index)) - result = {outpoint:txid} - if txid is None: - self.channel_status[outpoint] = 'open' + spender_txid = self.db.get_spent_outpoint(prev_txid, int(index)) + result = {outpoint:spender_txid} + if n == 0: + if spender_txid is None: + self.channel_status[outpoint] = 'open' + elif not self.is_deeply_mined(spender_txid): + self.channel_status[outpoint] = 'closed (%d)' % self.get_tx_height(spender_txid).conf + else: + self.channel_status[outpoint] = 'closed (deep)' + if spender_txid is None: return result - if n == 0 and not self.is_deeply_mined(txid): - self.channel_status[outpoint] = 'closed (%d)' % self.get_tx_height(txid).conf - else: - self.channel_status[outpoint] = 'closed (deep)' - tx = self.db.get_transaction(txid) - for i, o in enumerate(tx.outputs()): + spender_tx = self.db.get_transaction(spender_txid) + if n == 1: + # if tx input is not a first-stage HTLC, we can stop recursion + if len(spender_tx.inputs()) != 1: + return result + o = spender_tx.inputs()[0] + witness = o.witness_elements() + redeem_script = witness[-1] + if match_script_against_template(redeem_script, WITNESS_TEMPLATE_OFFERED_HTLC): + self.logger.info(f"input script matches offered htlc {redeem_script.hex()}") + elif match_script_against_template(redeem_script, WITNESS_TEMPLATE_RECEIVED_HTLC): + self.logger.info(f"input script matches received htlc {redeem_script.hex()}") + else: + return result + for i, o in enumerate(spender_tx.outputs()): if o.address is None: continue if not self.is_mine(o.address): self.add_address(o.address) elif n < 2: - r = self.inspect_tx_candidate(txid+':%d'%i, n+1) + r = self.inspect_tx_candidate(spender_txid+':%d'%i, n+1) result.update(r) return result diff --git a/electrum/transaction.py b/electrum/transaction.py index 1f18cee6a..343c4c6d2 100644 --- a/electrum/transaction.py +++ b/electrum/transaction.py @@ -482,7 +482,7 @@ def check_scriptpubkey_template_and_dust(scriptpubkey, amount: Optional[int]): raise Exception(f'amount ({amount}) is below dust limit for scriptpubkey type ({dust_limit})') -def match_script_against_template(script, template) -> bool: +def match_script_against_template(script, template, debug=False) -> bool: """Returns whether 'script' matches 'template'.""" if script is None: return False @@ -491,8 +491,14 @@ def match_script_against_template(script, template) -> bool: try: script = [x for x in script_GetOp(script)] except MalformedBitcoinScript: + if debug: + _logger.debug(f"malformed script") return False + if debug: + _logger.debug(f"match script against template: {script}") if len(script) != len(template): + if debug: + _logger.debug(f"length mismatch {len(script)} != {len(template)}") return False for i in range(len(script)): template_item = template[i] @@ -502,6 +508,8 @@ def match_script_against_template(script, template) -> bool: if OPGeneric.is_instance(template_item) and template_item.match(script_item[0]): continue if template_item != script_item[0]: + if debug: + _logger.debug(f"item mismatch at position {i}: {template_item} != {script_item[0]}") return False return True