diff --git a/contrib/make_libsecp256k1.sh b/contrib/make_libsecp256k1.sh index 40326d9c081a..5701b151bc62 100755 --- a/contrib/make_libsecp256k1.sh +++ b/contrib/make_libsecp256k1.sh @@ -55,6 +55,7 @@ info "Building $pkgname..." --enable-module-recovery \ --enable-module-extrakeys \ --enable-module-schnorrsig \ + --enable-module-musig \ --enable-experimental \ --enable-module-ecdh \ --disable-benchmark \ diff --git a/contrib/requirements/requirements.txt b/contrib/requirements/requirements.txt index e9963c12c95f..550d58501711 100644 --- a/contrib/requirements/requirements.txt +++ b/contrib/requirements/requirements.txt @@ -6,7 +6,7 @@ aiohttp>=3.11.0,<4.0.0 aiohttp_socks>=0.9.2 certifi jsonpatch -electrum_ecc>=0.0.4,<0.1 +electrum_ecc>=0.0.8,<0.1 electrum_aionostr>=0.1.0,<0.2 # - upper limit to avoid needing hatchling at build-time :/ diff --git a/electrum/bitcoin.py b/electrum/bitcoin.py index 7afde6e54c7c..4ff84f448f2b 100644 --- a/electrum/bitcoin.py +++ b/electrum/bitcoin.py @@ -793,13 +793,30 @@ def is_dummy_address(cls, addr: str) -> bool: class DummyAddressUsedInTxException(Exception): pass -def taproot_tweak_pubkey(pubkey32: bytes, h: bytes) -> Tuple[int, bytes]: +LEAF_VERSION_TAPSCRIPT = 0xC0 + + +def tapleaf_hash(*, leaf_version: int, script: bytes) -> bytes: + if not isinstance(script, bytes): + raise TypeError("tapleaf script must be bytes") + if type(leaf_version) is not int: + raise TypeError("tapleaf version must be an integer") + if not 0 <= leaf_version <= 0xFE or leaf_version & 1 or leaf_version == 0x50: + raise ValueError("invalid tapleaf version") + return bip340_tagged_hash(b"TapLeaf", bytes([leaf_version]) + witness_push(script)) + + +def taproot_tweak_hash(pubkey32: bytes, h: bytes) -> bytes: assert isinstance(pubkey32, bytes), type(pubkey32) assert isinstance(h, bytes), type(h) assert len(pubkey32) == 32, len(pubkey32) + return bip340_tagged_hash(b"TapTweak", pubkey32 + h) + + +def taproot_tweak_pubkey(pubkey32: bytes, h: bytes) -> Tuple[int, bytes]: int_from_bytes = lambda x: int.from_bytes(x, byteorder="big", signed=False) - tweak = int_from_bytes(bip340_tagged_hash(b"TapTweak", pubkey32 + h)) + tweak = int_from_bytes(taproot_tweak_hash(pubkey32, h)) if tweak >= ecc.CURVE_ORDER: raise ValueError P = ecc.ECPubkey(b"\x02" + pubkey32) @@ -816,7 +833,7 @@ def taproot_tweak_seckey(seckey0: bytes, h: bytes) -> bytes: P = ecc.ECPrivkey(seckey0) seckey = P.secret_scalar if P.has_even_y() else ecc.CURVE_ORDER - P.secret_scalar pubkey32 = P.get_public_key_bytes(compressed=True)[1:] - tweak = int_from_bytes(bip340_tagged_hash(b"TapTweak", pubkey32 + h)) + tweak = int_from_bytes(taproot_tweak_hash(pubkey32, h)) if tweak >= ecc.CURVE_ORDER: raise ValueError return int.to_bytes((seckey + tweak) % ecc.CURVE_ORDER, length=32, byteorder="big", signed=False) @@ -832,8 +849,9 @@ def taproot_tweak_seckey(seckey0: bytes, h: bytes) -> bytes: def taproot_tree_helper(script_tree: TapTree): if isinstance(script_tree, tuple): leaf_version, script = script_tree - h = bip340_tagged_hash(b"TapLeaf", bytes([leaf_version]) + witness_push(script)) - return [((leaf_version, script), bytes())], h + return [((leaf_version, script), bytes())], tapleaf_hash( + leaf_version=leaf_version, script=script + ) left, left_h = taproot_tree_helper(script_tree[0]) right, right_h = taproot_tree_helper(script_tree[1]) ret = [(l, c + right_h) for l, c in left] + [(l, c + left_h) for l, c in right] diff --git a/electrum/descriptor.py b/electrum/descriptor.py index fcf2dd664295..dc32331b8109 100644 --- a/electrum/descriptor.py +++ b/electrum/descriptor.py @@ -32,7 +32,9 @@ from .bip32 import convert_bip32_strpath_to_intpath, BIP32Node, KeyOriginInfo, BIP32_PRIME from . import bitcoin -from .bitcoin import construct_script, opcodes, construct_witness, taproot_output_script +from .bitcoin import ( + LEAF_VERSION_TAPSCRIPT, construct_script, opcodes, construct_witness, taproot_output_script, +) from . import constants from .crypto import hash_160, sha256 from . import segwit_addr @@ -808,9 +810,8 @@ def expand(self, *, pos: Optional[int] = None) -> "ExpandedScripts": if self.desc_tree: def transform(tree_node): if isinstance(tree_node, Descriptor): - leaf_version = 0xc0 leaf_script = tree_node.expand(pos=pos).scriptcode_for_sighash # FIXME maybe rename scriptcode_for_sighash - return (leaf_version, leaf_script) + return (LEAF_VERSION_TAPSCRIPT, leaf_script) assert len(tree_node) == 2, len(tree_node) return [transform(tree_node[0]), transform(tree_node[1])] script_tree = transform(self.desc_tree) diff --git a/electrum/plugins/swapserver/server.py b/electrum/plugins/swapserver/server.py index 7d645e7e8f1c..9ef46315d59b 100644 --- a/electrum/plugins/swapserver/server.py +++ b/electrum/plugins/swapserver/server.py @@ -31,6 +31,7 @@ from electrum.util import log_exceptions, ignore_exceptions from electrum.logging import Logger +from electrum.submarine_swaps import TAPROOT_COOPERATIVE_CAPABILITY, TAPROOT_SWAP_PROTOCOL from electrum.util import EventListener if TYPE_CHECKING: @@ -71,6 +72,8 @@ async def run(self): app.add_routes([web.post('/createswap', self.create_swap)]) app.add_routes([web.post('/createnormalswap', self.create_normal_swap)]) app.add_routes([web.post('/addswapinvoice', self.add_swap_invoice)]) + app.add_routes([web.post('/claimtaprootswap', self.claim_taproot_swap)]) + app.add_routes([web.post('/refundtaprootswap', self.refund_taproot_swap)]) runner = web.AppRunner(app) await runner.setup() @@ -85,6 +88,7 @@ async def get_pairs(self, r): "info": [], "warnings": [], "htlcFirst": True, + "protocols": [TAPROOT_SWAP_PROTOCOL, TAPROOT_COOPERATIVE_CAPABILITY], "pairs": { "BTC/BTC": { "rate": 1, @@ -134,3 +138,13 @@ async def create_swap(self, r): request = await r.json() response = self.sm.server_create_swap(request) return web.json_response(response) + + async def claim_taproot_swap(self, r): + request = await r.json() + response = self.sm.server_claim_taproot_swap(request) + return web.json_response(response) + + async def refund_taproot_swap(self, r): + request = await r.json() + response = self.sm.server_refund_taproot_swap(request) + return web.json_response(response) diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py index 62aa1ad813e5..96ef2fc384bd 100644 --- a/electrum/submarine_swaps.py +++ b/electrum/submarine_swaps.py @@ -1,6 +1,8 @@ import asyncio +import copy import json import os +import secrets import ssl import threading from concurrent.futures import Future @@ -12,7 +14,8 @@ import attr import aiohttp -from electrum_ecc import ECPrivkey +from electrum_ecc import ECPrivkey, ECPubkey +from electrum_ecc.util import bip340_tagged_hash import electrum_aionostr as aionostr import electrum_aionostr.key @@ -29,9 +32,11 @@ from .bitcoin import (script_to_p2wsh, opcodes, dust_threshold, DummyAddress, construct_witness, construct_script, address_to_script) from . import bitcoin +from .taproot_swaps import MuSig2Session, SwapDirection, SwapLeaf, TaprootSwapContract from .transaction import ( PartialTxInput, PartialTxOutput, PartialTransaction, Transaction, TxInput, TxOutpoint, script_GetOp, - match_script_against_template, OPPushDataGeneric, OPPushDataPubkey, TxOutput, + match_script_against_template, OPPushDataGeneric, OPPushDataPubkey, Sighash, + TapScriptSigningData, TxOutput, ) from .util import ( log_exceptions, ignore_exceptions, BelowDustLimit, OldTaskGroup, ca_path, gen_nostr_ann_pow, @@ -45,8 +50,8 @@ from . import constants from .address_synchronizer import (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE, TX_HEIGHT_UNCONFIRMED, TX_HEIGHT_UNCONF_PARENT) -from .fee_policy import FeePolicy -from .invoices import Invoice, PR_PAID +from .fee_policy import FEERATE_WARNING_HIGH_FEE, FeePolicy +from .invoices import Invoice, PR_INFLIGHT, PR_PAID from .lnonion import OnionRoutingFailure, OnionFailureCode from .lnsweep import SweepInfo @@ -73,6 +78,11 @@ # a large tx consolidating many UTXOs or claiming/funding multiple swaps, it can end up paying more # on-chain fees than the nostr-advertised base mining fees. In some cases swap profits can go negative # for a server for a given swap (high mempool feerates AND low swap value AND tx_batcher creates tx with many inputs). +COOPERATIVE_SWAP_TX_SIZE = 111 +TAPROOT_SWAP_PROTOCOL = "taproot-v1" +TAPROOT_COOPERATIVE_CAPABILITY = "taproot-cooperative-v1" +MUSIG_SESSION_TTL_SEC = 90 +MUSIG_SESSION_MAX_ENTRIES = 32 MIN_SWAP_AMOUNT_SAT = 20_000 MIN_LOCKTIME_DELTA = 60 @@ -164,6 +174,31 @@ def _construct_swap_scriptcode( ) +def _parse_compressed_pubkey(value: object, *, name: str) -> bytes: + if not isinstance(value, str): + raise ValueError(f"{name} must be hex") + try: + pubkey = bytes.fromhex(value) + except ValueError as e: + raise ValueError(f"{name} must be hex") from e + if len(pubkey) != 33: + raise ValueError(f"{name} must be 33 bytes") + try: + ECPubkey(pubkey) + except Exception as e: + raise ValueError(f"{name} is not a valid public key") from e + return pubkey + + +def _parse_swap_protocols(value: object) -> Tuple[str, ...]: + if (not isinstance(value, list) or len(value) > 8 + or any(not isinstance(protocol, str) or not protocol.isascii() + or not 1 <= len(protocol) <= 32 for protocol in value) + or len(value) != len(set(value))): + raise ValueError("invalid swap protocol capability list") + return tuple(value) + + class SwapServerError(Exception): def __init__(self, message=None): self.message = message @@ -191,6 +226,7 @@ class SwapOffer: pow_bits = attr.ib(type=int) server_pubkey = attr.ib(type=str) timestamp = attr.ib(type=int) + protocols = attr.ib(type=Tuple[str, ...], converter=tuple, default=()) @property def server_npub(self): @@ -213,10 +249,19 @@ class SwapData(StoredObject): funding_txid = attr.ib(type=Optional[str]) spending_txid = attr.ib(type=Optional[str]) is_redeemed = attr.ib(type=bool) + protocol = attr.ib(type=Optional[str], default=None) + their_pubkey = attr.ib(type=Optional[bytes], converter=hex_to_bytes, default=None) + is_provider = attr.ib(type=bool, default=False) + cooperative_tx = attr.ib(type=Optional[str], default=None) + is_cooperative = attr.ib(type=bool, default=False) + refund_cancelled = attr.ib(type=bool, default=False) _funding_prevout = None # type: Optional[TxOutpoint] # for RBF _payment_hash = None _payment_pending = False # for forward swaps + _lightning_payment_pending = False + _coop_spend_pending = False + _coop_spend_failed = False @property def payment_hash(self) -> bytes: @@ -266,6 +311,10 @@ def __init__(self, *, wallet: 'Abstract_Wallet', lnworker: 'LNWallet'): for payment_hash_hex, swap in swaps.items(): payment_hash = bytes.fromhex(payment_hash_hex) swap._payment_hash = payment_hash + if swap.protocol not in (None, TAPROOT_SWAP_PROTOCOL): + raise ValueError("unsupported persisted swap protocol") + if swap.protocol == TAPROOT_SWAP_PROTOCOL: + self._validate_taproot_swap(swap) self._reindex_swap(swap.payment_hash, swap) if not swap.is_reverse and not swap.is_redeemed and not self.lnworker.get_preimage(swap.payment_hash): self.lnworker.register_hold_invoice(payment_hash, self.hold_invoice_callback) @@ -274,6 +323,9 @@ def __init__(self, *, wallet: 'Abstract_Wallet', lnworker: 'LNWallet'): for k, swap in self._swaps.items(): if swap.prepay_hash is not None: self._prepayments[swap.prepay_hash] = bytes.fromhex(k) + self.invoices_to_pay = {} + # MuSig secret nonces are intentionally memory-only. + self._musig_sessions = {} self.is_server = False # overridden by swapserver plugin if enabled self.is_initialized = asyncio.Event() self.pairs_updated = asyncio.Event() @@ -438,7 +490,15 @@ async def set_nostr_proof_of_work(self) -> None: async def pay_invoice(self, key): self.logger.info(f'trying to pay invoice {key}') - self.invoices_to_pay[key] = 1000000000000 # lock + with self.swaps_lock: + swap = self._swaps.get(key) + if swap is not None and swap.refund_cancelled: + self.logger.info(f'not paying cancelled swap invoice {key}') + self.invoices_to_pay.pop(key, None) + return + self.invoices_to_pay[key] = 1000000000000 # lock + if swap is not None: + swap._lightning_payment_pending = True try: invoice = self.wallet.get_invoice(key) success, log = await self.lnworker.pay_invoice(invoice) @@ -446,6 +506,10 @@ async def pay_invoice(self, key): self.logger.info(f'exception paying {key}, will not retry') self.invoices_to_pay.pop(key, None) return + finally: + if swap is not None: + with self.swaps_lock: + swap._lightning_payment_pending = False if not success: self.logger.info(f'failed to pay {key}, will retry in 10 minutes') self.invoices_to_pay[key] = now() + 600 @@ -454,7 +518,6 @@ async def pay_invoice(self, key): self.invoices_to_pay.pop(key, None) async def pay_pending_invoices(self): - self.invoices_to_pay = {} while True: await asyncio.sleep(5) for key, not_before in list(self.invoices_to_pay.items()): @@ -510,6 +573,107 @@ def extract_preimage(cls, swap: SwapData, claim_tx: Transaction) -> Optional[byt return preimage return None + @staticmethod + def _get_swap_pubkeys(swap: SwapData) -> Tuple[bytes, bytes]: + if swap.their_pubkey is None: + raise ValueError("Taproot swap is missing the counterparty public key") + our_pubkey = ECPrivkey(swap.privkey).get_public_key_bytes(compressed=True) + if swap.is_provider: + return our_pubkey, swap.their_pubkey + return swap.their_pubkey, our_pubkey + + @classmethod + def get_taproot_contract(cls, swap: SwapData) -> TaprootSwapContract: + provider_pubkey, user_pubkey = cls._get_swap_pubkeys(swap) + direction = ( + SwapDirection.REVERSE + if swap.is_reverse ^ swap.is_provider + else SwapDirection.FORWARD + ) + return TaprootSwapContract( + direction=direction, + payment_hash=swap.payment_hash, + locktime=swap.locktime, + provider_pubkey=provider_pubkey, + user_pubkey=user_pubkey, + ) + + @classmethod + def _validate_taproot_swap(cls, swap: SwapData) -> TaprootSwapContract: + if swap.protocol != TAPROOT_SWAP_PROTOCOL: + raise ValueError("unsupported persisted Taproot swap protocol") + if (type(swap.is_reverse) is not bool or type(swap.is_provider) is not bool + or type(swap.is_cooperative) is not bool + or type(swap.refund_cancelled) is not bool): + raise ValueError("invalid persisted Taproot swap roles") + if type(swap.onchain_amount) is not int or swap.onchain_amount < dust_threshold(): + raise ValueError("invalid persisted Taproot swap amount") + if swap.preimage is not None and sha256(swap.preimage) != swap.payment_hash: + raise ValueError("persisted Taproot swap preimage does not match payment hash") + contract = cls.get_taproot_contract(swap) + if swap.redeem_script != contract.output_script: + raise ValueError("persisted Taproot swap output script does not match contract") + if swap.lockup_address != contract.address(): + raise ValueError("persisted Taproot swap address does not match contract") + if swap.cooperative_tx is not None: + if not swap.is_cooperative: + raise ValueError("non-cooperative Taproot swap has a cooperative transaction") + cooperative_tx, msg32 = cls._parse_persisted_cooperative_transaction(swap) + if swap.spending_txid != cooperative_tx.txid(): + raise ValueError("persisted cooperative swap transaction ID does not match") + cls._verify_cooperative_witness(swap, cooperative_tx, msg32=msg32) + return contract + + @classmethod + def _parse_persisted_cooperative_transaction( + cls, swap: SwapData, + ) -> Tuple[Transaction, bytes]: + try: + tx = Transaction(swap.cooperative_tx) + except Exception as e: + raise ValueError("invalid persisted cooperative swap transaction") from e + if len(tx.inputs()) != 1 or tx.serialize_to_network() != swap.cooperative_tx: + raise ValueError("persisted cooperative transaction identity changed") + unsigned_tx = copy.deepcopy(tx) + unsigned_tx.inputs()[0].witness = None + _checked_tx, msg32 = cls._parse_cooperative_transaction( + cls, swap, unsigned_tx.serialize_to_network(include_sigs=False), + require_confirmed=False, + funding_txout=TxOutput(scriptpubkey=swap.redeem_script, value=swap.onchain_amount), + ) + return tx, msg32 + + async def _rebroadcast_cooperative_spend(self, swap: SwapData) -> None: + if swap.cooperative_tx is None: + return + try: + SwapManager._validate_taproot_swap(swap) + await self.network.broadcast_transaction(Transaction(swap.cooperative_tx)) + except Exception as e: + self.logger.info( + f'cooperative spend rebroadcast failed for {swap.payment_hash.hex()}: {e!r}' + ) + + def _reconstruct_taproot_funding_txin( + self, swap: SwapData, txin: PartialTxInput, *, require_confirmed: bool, + ) -> TxOutput: + self._validate_taproot_swap(swap) + funding_txid = txin.prevout.txid.hex() + if require_confirmed and self.lnwatcher.adb.get_tx_height(funding_txid).conf <= 0: + raise ValueError("Taproot funding output is not confirmed") + try: + funding_txout = self.lnwatcher.adb.get_transaction( + funding_txid + ).outputs()[txin.prevout.out_idx] + except (AttributeError, IndexError) as e: + raise ValueError("Taproot funding transaction is unavailable") from e + if (funding_txout.value != swap.onchain_amount + or funding_txout.scriptpubkey != swap.redeem_script + or txin.value_sats() != swap.onchain_amount): + raise ValueError("funding output does not match Taproot swap contract") + txin.witness_utxo = funding_txout + return funding_txout + @log_exceptions async def _claim_swap(self, swap: SwapData) -> None: assert self.network @@ -520,7 +684,16 @@ async def _claim_swap(self, swap: SwapData) -> None: remaining_time = swap.locktime - current_height txos = self.lnwatcher.adb.get_addr_outputs(swap.lockup_address) - for txin in txos.values(): + if swap.cooperative_tx is not None: + cooperative_tx, _msg32 = self._parse_persisted_cooperative_transaction(swap) + txin = txos.get(cooperative_tx.inputs()[0].prevout) + candidates = (txin,) if txin is not None else () + else: + candidates = txos.values() + for txin in candidates: + if (swap.protocol == TAPROOT_SWAP_PROTOCOL + and txin.value_sats() != swap.onchain_amount): + continue if swap.is_reverse and txin.value_sats() < swap.onchain_amount: # amount too low, we must not reveal the preimage continue @@ -535,17 +708,31 @@ async def _claim_swap(self, swap: SwapData) -> None: if txin: # the swap is funded + funding_height = self.lnwatcher.adb.get_tx_height(txin.prevout.txid.hex()) + if swap.protocol == TAPROOT_SWAP_PROTOCOL: + try: + self._reconstruct_taproot_funding_txin( + swap, + txin, + require_confirmed=swap.is_reverse and not swap.is_provider, + ) + except ValueError as e: + self.logger.info(f'ignoring invalid Taproot funding output: {e}') + return # note: swap.funding_txid can change due to RBF, it will get updated here: swap.funding_txid = txin.prevout.txid.hex() swap._funding_prevout = txin.prevout self._reindex_swap(swap.payment_hash, swap) # to update _swaps_by_funding_outpoint - funding_height = self.lnwatcher.adb.get_tx_height(txin.prevout.txid.hex()) spent_height = txin.spent_height # set spending_txid (even if tx is local), for GUI grouping - swap.spending_txid = txin.spent_txid - # discard local spenders + if txin.spent_txid is not None and swap.cooperative_tx is None: + swap.spending_txid = txin.spent_txid + # Never construct a competing spend while a local transaction exists. if spent_height in [TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE]: - spent_height = None + if (swap.cooperative_tx is not None + and txin.spent_txid == swap.spending_txid): + await self._rebroadcast_cooperative_spend(swap) + return if spent_height is not None: if spent_height > 0 and swap.preimage: if current_height - spent_height > REDEEM_AFTER_DOUBLE_SPENT_DELAY: @@ -557,6 +744,11 @@ async def _claim_swap(self, swap: SwapData) -> None: self.lnworker.delete_payment_bundle(payment_hash=swap.payment_hash) self.lnworker.unregister_hold_invoice(swap.payment_hash) + if swap.cooperative_tx is not None: + if txin.spent_txid is None: + await self._rebroadcast_cooperative_spend(swap) + return + if not swap.is_reverse: if swap.preimage is None and spent_height is not None: # extract the preimage, add it to lnwatcher @@ -599,7 +791,18 @@ async def _claim_swap(self, swap: SwapData) -> None: # for testing: do not create claim tx return - if spent_height is not None and spent_height > 0: + if txin.spent_txid is not None or spent_height is not None and spent_height > 0: + return + if (swap.protocol == TAPROOT_SWAP_PROTOCOL and swap.is_cooperative + and not swap.is_provider + and not swap._coop_spend_failed): + if not swap._coop_spend_pending: + swap._coop_spend_pending = True + try: + await self.taskgroup.spawn(self._cooperative_spend(swap, txin)) + except BaseException: + swap._coop_spend_pending = False + raise return txin, locktime = self.create_claim_txin(txin=txin, swap=swap) if swap.is_reverse and swap.claim_to_output: @@ -661,6 +864,229 @@ async def _claim_to_output(self, swap: SwapData, claim_txin: PartialTxInput): except Exception: self.logger.exception(f"cannot broadcast swap to output claim tx") + def _get_taproot_funding_output( + self, + swap: SwapData, + *, + prevout: Optional[TxOutpoint] = None, + require_confirmed: bool = False, + ) -> Tuple[PartialTxInput, TxOutput]: + prevout = prevout or swap._funding_prevout + if swap.funding_txid is None or prevout is None: + raise ValueError("Taproot swap funding output is unavailable") + if prevout.txid.hex() != swap.funding_txid: + raise ValueError("cooperative input does not match funding transaction") + if swap._funding_prevout is not None and prevout != swap._funding_prevout: + raise ValueError("cooperative input does not match funding output") + adb = self.lnwatcher.adb + watched_txin = adb.get_addr_outputs(swap.lockup_address).get(prevout) + if watched_txin is None: + raise ValueError("Taproot swap funding output is not watched") + if watched_txin.spent_txid is not None or swap.spending_txid is not None: + raise ValueError("Taproot swap funding output already has a spender") + txin = copy.deepcopy(watched_txin) + funding_txout = self._reconstruct_taproot_funding_txin( + swap, txin, require_confirmed=require_confirmed, + ) + txin.script_sig = b'' + txin.witness = None + txin.nsequence = 0xffffffff - 2 + return txin, funding_txout + + def _parse_cooperative_transaction( + self, + swap: SwapData, + raw_tx: object, + *, + expected_destination: Optional[str] = None, + expected_value: Optional[int] = None, + require_confirmed: bool, + funding_txout: Optional[TxOutput] = None, + ) -> Tuple[PartialTransaction, bytes]: + if (not isinstance(raw_tx, str) or not raw_tx or len(raw_tx) > 200_000 + or len(raw_tx) % 2): + raise ValueError("invalid cooperative transaction") + if expected_destination is not None and ( + not isinstance(expected_destination, str) + or not bitcoin.is_address(expected_destination)): + raise ValueError("invalid cooperative destination") + if expected_value is not None and type(expected_value) is not int: + raise ValueError("invalid cooperative output value") + try: + network_tx = Transaction(raw_tx) + inputs, outputs = network_tx.inputs(), network_tx.outputs() + except Exception as e: + raise ValueError("invalid cooperative transaction") from e + if (network_tx.version != 2 or network_tx.locktime != 0 + or len(inputs) != 1 or len(outputs) != 1): + raise ValueError("cooperative transaction shape is not canonical") + network_txin = inputs[0] + if (network_txin.script_sig != b'' or network_txin.witness is not None + or network_txin.nsequence != 0xffffffff - 2 + or network_tx.serialize_to_network(include_sigs=False) != raw_tx): + raise ValueError("cooperative transaction input is not unsigned and canonical") + if (network_txin.prevout.txid.hex() != swap.funding_txid + or (swap._funding_prevout is not None + and network_txin.prevout != swap._funding_prevout)): + raise ValueError("cooperative input does not match funding output") + if funding_txout is None: + _funding_txin, funding_txout = self._get_taproot_funding_output( + swap, prevout=network_txin.prevout, require_confirmed=require_confirmed, + ) + output = outputs[0] + destination = output.address + fee = funding_txout.value - output.value + max_fee = COOPERATIVE_SWAP_TX_SIZE * FEERATE_WARNING_HIGH_FEE // 1000 + if (type(output.value) is not int or output.value < dust_threshold() + or destination is None or not bitcoin.is_address(destination) + or expected_destination is not None and destination != expected_destination + or expected_value is not None and output.value != expected_value + or not 0 < fee <= max_fee): + raise ValueError("invalid cooperative transaction destination, amount, or fee") + tx = PartialTransaction.from_tx(network_tx) + tx.inputs()[0].witness_utxo = funding_txout + pre_hash = tx.serialize_preimage(0, sighash=Sighash.DEFAULT) + return tx, bip340_tagged_hash(b'TapSighash', pre_hash) + + @classmethod + def _verify_cooperative_witness( + cls, + swap: SwapData, + tx: Transaction, + *, + msg32: bytes, + expected_signature: Optional[bytes] = None, + ) -> None: + witness = tx.inputs()[0].witness_elements() + if (len(witness) != 1 or len(witness[0]) != 64 + or expected_signature is not None and witness[0] != expected_signature): + raise ValueError("cooperative final signature is invalid") + output_pubkey = ECPubkey(b'\x02' + cls.get_taproot_contract(swap).output_script[2:]) + if not output_pubkey.schnorr_verify(witness[0], msg32): + raise ValueError("cooperative final signature is invalid") + + async def _send_cooperative_request(self, method: str, request: dict) -> dict: + async with self.create_transport() as transport: + try: + await wait_for2(transport.is_connected.wait(), timeout=15) + return await wait_for2( + transport.send_request_to_server(method, request), timeout=30, + ) + except asyncio.TimeoutError as e: + raise SwapServerError("swap provider did not reply or connect") from e + + async def _create_cooperative_spend_tx( + self, swap: SwapData, funding_txin: PartialTxInput, + ) -> Transaction: + if (swap.protocol != TAPROOT_SWAP_PROTOCOL or not swap.is_cooperative + or swap.is_provider): + raise ValueError("cooperative spend requires a client Taproot swap") + if not swap.is_reverse and self.network.get_local_height() < swap.locktime: + raise ValueError("cooperative refund timeout has not been reached") + if swap.is_reverse and ( + swap.preimage is None or sha256(swap.preimage) != swap.payment_hash): + raise ValueError("reverse Taproot swap preimage is unavailable") + txin, funding_txout = self._get_taproot_funding_output( + swap, prevout=funding_txin.prevout, require_confirmed=True, + ) + if swap.is_reverse and swap.claim_to_output: + destination, value = swap.claim_to_output + else: + destination = self.wallet.get_receiving_address() + value = funding_txout.value - self.get_fee_for_txbatcher() + if value < dust_threshold(): + raise BelowDustLimit() + txout = PartialTxOutput.from_address_and_value(destination, value) + tx = PartialTransaction.from_io( + [txin], [txout], locktime=0, version=2, BIP69_sort=False, + ) + unsigned_tx = tx.serialize_to_network(include_sigs=False) + _checked_tx, msg32 = self._parse_cooperative_transaction( + swap, + unsigned_tx, + expected_destination=destination, + expected_value=value, + require_confirmed=True, + ) + session_id = secrets.token_bytes(32) + signer = MuSig2Session.create( + contract=self.get_taproot_contract(swap), + local_seckey=swap.privkey, + msg32=msg32, + session_id32=session_id, + ) + method = 'claimtaprootswap' if swap.is_reverse else 'refundtaprootswap' + request = { + 'id': swap.payment_hash.hex(), + 'sessionId': session_id.hex(), + 'transaction': unsigned_tx, + } + if swap.is_reverse: + request['preimage'] = swap.preimage.hex() + response = await self._send_cooperative_request(method, request) + if set(response) != {'sessionId', 'pubNonce', 'transaction'}: + raise ValueError("invalid cooperative nonce response fields") + try: + provider_nonce = bytes.fromhex(response['pubNonce']) + except (TypeError, ValueError) as e: + raise ValueError("invalid cooperative nonce response") from e + if (len(provider_nonce) != 66 or response['sessionId'] != session_id.hex() + or response['transaction'] != unsigned_tx): + raise ValueError("cooperative nonce response changed transaction identity") + client_partial = signer.sign_partial(provider_nonce) + response = await self._send_cooperative_request(method, { + 'id': swap.payment_hash.hex(), + 'sessionId': session_id.hex(), + 'pubNonce': signer.public_nonce.hex(), + 'partialSignature': client_partial.hex(), + }) + if set(response) != {'sessionId', 'partialSignature', 'transaction'}: + raise ValueError("invalid cooperative signed response fields") + try: + signed_tx = Transaction(response['transaction']) + provider_partial = bytes.fromhex(response['partialSignature']) + except (TypeError, ValueError, IndexError) as e: + raise ValueError("invalid cooperative signed response") from e + if (len(provider_partial) != 32 + or signed_tx.serialize_to_network(include_sigs=False) != unsigned_tx + or response['sessionId'] != session_id.hex()): + raise ValueError("cooperative signed response changed transaction identity") + signature = signer.aggregate(provider_partial) + self._verify_cooperative_witness( + swap, signed_tx, msg32=msg32, expected_signature=signature, + ) + raw_signed_tx = signed_tx.serialize_to_network() + swap.cooperative_tx = raw_signed_tx + swap.spending_txid = signed_tx.txid() + return signed_tx + + @log_exceptions + async def _cooperative_spend(self, swap: SwapData, txin: PartialTxInput) -> None: + try: + tx = await self._create_cooperative_spend_tx(swap, txin) + if not self.wallet.adb.get_transaction(tx.txid()): + try: + self.wallet.adb.add_transaction(tx) + except Exception: + self.logger.exception("could not add cooperative swap transaction") + except Exception as e: + self.logger.info( + f'cooperative spend failed for {swap.payment_hash.hex()}, ' + f'using script fallback: {e!r}' + ) + swap._coop_spend_failed = True + else: + try: + await self.network.broadcast_transaction(tx) + except Exception as e: + self.logger.info( + f'cooperative spend broadcast failed for {tx.txid()}, will rebroadcast: {e!r}' + ) + finally: + swap._coop_spend_pending = False + if swap._coop_spend_failed: + await self._claim_swap(swap) + def get_fee_for_txbatcher(self): return self._get_tx_fee(self.config.FEE_POLICY_SWAPS) @@ -713,7 +1139,14 @@ async def hold_invoice_callback(self, payment_hash: bytes) -> None: else: self.logger.info(f'key not in swaps {key}') - def create_normal_swap(self, *, lightning_amount_sat: int, payment_hash: bytes, their_pubkey: bytes = None): + def create_normal_swap( + self, + *, + lightning_amount_sat: int, + payment_hash: bytes, + their_pubkey: bytes, + is_taproot: bool = False, + ): """ server method """ assert lightning_amount_sat if payment_hash.hex() in self._swaps: @@ -726,12 +1159,22 @@ def create_normal_swap(self, *, lightning_amount_sat: int, payment_hash: bytes, onchain_amount_sat = self._get_recv_amount(lightning_amount_sat, is_reverse=True) # what the client is going to receive if not onchain_amount_sat: raise Exception("no onchain amount") - redeem_script = _construct_swap_scriptcode( - payment_hash=payment_hash, - locktime=locktime, - refund_pubkey=our_pubkey, - claim_pubkey=their_pubkey, - ) + if is_taproot: + contract = TaprootSwapContract( + direction=SwapDirection.REVERSE, + payment_hash=payment_hash, + locktime=locktime, + provider_pubkey=our_pubkey, + user_pubkey=their_pubkey, + ) + redeem_script = contract.output_script + else: + redeem_script = _construct_swap_scriptcode( + payment_hash=payment_hash, + locktime=locktime, + refund_pubkey=our_pubkey, + claim_pubkey=their_pubkey, + ) swap, invoice, prepay_invoice = self.add_normal_swap( redeem_script=redeem_script, locktime=locktime, @@ -740,6 +1183,10 @@ def create_normal_swap(self, *, lightning_amount_sat: int, payment_hash: bytes, payment_hash=payment_hash, our_privkey=our_privkey, prepay=True, + protocol=TAPROOT_SWAP_PROTOCOL if is_taproot else None, + their_pubkey=their_pubkey if is_taproot else None, + is_provider=is_taproot, + is_cooperative=is_taproot, ) self.lnworker.register_hold_invoice(payment_hash, self.hold_invoice_callback) return swap, invoice, prepay_invoice @@ -755,6 +1202,10 @@ def add_normal_swap( prepay: bool, channels: Optional[Sequence['Channel']] = None, min_final_cltv_expiry_delta: Optional[int] = None, + protocol: Optional[str] = None, + their_pubkey: Optional[bytes] = None, + is_provider: bool = False, + is_cooperative: bool = False, ) -> Tuple[SwapData, str, Optional[str]]: """creates a hold invoice""" if payment_hash.hex() in self._swaps: @@ -807,7 +1258,12 @@ def add_normal_swap( prepay_invoice = None prepay_hash = None - lockup_address = script_to_p2wsh(redeem_script) + lockup_address = ( + bitcoin.script_to_address(redeem_script) + if protocol == TAPROOT_SWAP_PROTOCOL else script_to_p2wsh(redeem_script) + ) + if lockup_address is None: + raise ValueError("invalid Taproot swap output script") swap = SwapData( redeem_script=redeem_script, locktime=locktime, @@ -822,12 +1278,21 @@ def add_normal_swap( is_redeemed=False, funding_txid=None, spending_txid=None, + protocol=protocol, + their_pubkey=their_pubkey, + is_provider=is_provider, + is_cooperative=is_cooperative, ) + swap._payment_hash = payment_hash + if protocol == TAPROOT_SWAP_PROTOCOL: + self._validate_taproot_swap(swap) self._add_swap(payment_hash, swap) self.add_lnwatcher_callback(swap) return swap, invoice, prepay_invoice - def create_reverse_swap(self, *, lightning_amount_sat: int, their_pubkey: bytes) -> SwapData: + def create_reverse_swap( + self, *, lightning_amount_sat: int, their_pubkey: bytes, is_taproot: bool = False, + ) -> SwapData: """ server method. """ assert lightning_amount_sat is not None locktime = self.network.get_local_height() + LOCKTIME_DELTA_REFUND @@ -840,12 +1305,22 @@ def create_reverse_swap(self, *, lightning_amount_sat: int, their_pubkey: bytes) raise Exception("no onchain amount") preimage = os.urandom(32) payment_hash = sha256(preimage) - redeem_script = _construct_swap_scriptcode( - payment_hash=payment_hash, - locktime=locktime, - refund_pubkey=their_pubkey, - claim_pubkey=our_pubkey, - ) + if is_taproot: + contract = TaprootSwapContract( + direction=SwapDirection.FORWARD, + payment_hash=payment_hash, + locktime=locktime, + provider_pubkey=our_pubkey, + user_pubkey=their_pubkey, + ) + redeem_script = contract.output_script + else: + redeem_script = _construct_swap_scriptcode( + payment_hash=payment_hash, + locktime=locktime, + refund_pubkey=their_pubkey, + claim_pubkey=our_pubkey, + ) swap = self.add_reverse_swap( redeem_script=redeem_script, locktime=locktime, @@ -854,7 +1329,12 @@ def create_reverse_swap(self, *, lightning_amount_sat: int, their_pubkey: bytes) payment_hash=payment_hash, prepay_hash=None, onchain_amount_sat=onchain_amount_sat, - lightning_amount_sat=lightning_amount_sat) + lightning_amount_sat=lightning_amount_sat, + protocol=TAPROOT_SWAP_PROTOCOL if is_taproot else None, + their_pubkey=their_pubkey if is_taproot else None, + is_provider=is_taproot, + is_cooperative=is_taproot, + ) return swap def add_reverse_swap( @@ -869,11 +1349,20 @@ def add_reverse_swap( payment_hash: bytes, prepay_hash: Optional[bytes] = None, claim_to_output: Optional[TxOutput] = None, + protocol: Optional[str] = None, + their_pubkey: Optional[bytes] = None, + is_provider: bool = False, + is_cooperative: bool = False, ) -> SwapData: if payment_hash.hex() in self._swaps: raise Exception("payment_hash already in use") assert sha256(preimage) == payment_hash - lockup_address = script_to_p2wsh(redeem_script) + lockup_address = ( + bitcoin.script_to_address(redeem_script) + if protocol == TAPROOT_SWAP_PROTOCOL else script_to_p2wsh(redeem_script) + ) + if lockup_address is None: + raise ValueError("invalid Taproot swap output script") if claim_to_output is not None: # the claim_to_output value needs to be lower than the funding utxo value, otherwise # there are no funds left for the fee of the claim tx @@ -893,11 +1382,18 @@ def add_reverse_swap( is_redeemed=False, funding_txid=None, spending_txid=None, + protocol=protocol, + their_pubkey=their_pubkey, + is_provider=is_provider, + is_cooperative=is_cooperative, ) if prepay_hash: if prepay_hash in self._prepayments: raise Exception("prepay_hash already in use") self._prepayments[prepay_hash] = payment_hash + swap._payment_hash = payment_hash + if protocol == TAPROOT_SWAP_PROTOCOL: + self._validate_taproot_swap(swap) self._add_swap(payment_hash, swap) self.add_lnwatcher_callback(swap) return swap @@ -906,31 +1402,235 @@ def server_add_swap_invoice(self, request: dict) -> dict: """ server method. (client-forward-swap phase2) """ - invoice = request['invoice'] - invoice = Invoice.from_bech32(invoice) - key = invoice.rhash - payment_hash = bytes.fromhex(key) - their_pubkey = bytes.fromhex(request['refundPublicKey']) + if type(request) is not dict: + raise ValueError("add swap invoice request must be an object") + try: + invoice = Invoice.from_bech32(request['invoice']) + their_pubkey = _parse_compressed_pubkey(request['refundPublicKey'], name="refund public key") + invoice_amount = int(invoice.get_amount_sat()) + key = invoice.rhash + payment_hash = bytes.fromhex(key) + except Exception as e: + raise ValueError("invalid add swap invoice request") from e with self.swaps_lock: - assert key in self._swaps - swap = self._swaps[key] - assert swap.lightning_amount == int(invoice.get_amount_sat()) - assert swap.is_reverse is True + swap = self._swaps.get(key) + if swap is None: + raise ValueError("unknown swap") + if swap.lightning_amount != invoice_amount: + raise ValueError("invoice amount does not match swap") + if swap.is_reverse is not True: + raise ValueError("swap direction does not accept an invoice") # check that we have the preimage - assert sha256(swap.preimage) == payment_hash - assert swap.spending_txid is None - # check their_pubkey by recalculating redeem_script - our_pubkey = ECPrivkey(swap.privkey).get_public_key_bytes(compressed=True) - redeem_script = _construct_swap_scriptcode( - payment_hash=payment_hash, locktime=swap.locktime, refund_pubkey=their_pubkey, claim_pubkey=our_pubkey, - ) - assert swap.redeem_script == redeem_script - assert key not in self.invoices_to_pay - self.invoices_to_pay[key] = 0 - assert self.wallet.get_invoice(invoice.get_id()) is None + if swap.preimage is None or sha256(swap.preimage) != payment_hash: + raise ValueError("swap preimage does not match invoice") + if swap.spending_txid is not None: + raise ValueError("swap funding output is already spent") + if swap.protocol == TAPROOT_SWAP_PROTOCOL: + if not swap.is_provider or swap.their_pubkey != their_pubkey: + raise ValueError("refund public key does not match Taproot swap") + self._validate_taproot_swap(swap) + else: + our_pubkey = ECPrivkey(swap.privkey).get_public_key_bytes(compressed=True) + redeem_script = _construct_swap_scriptcode( + payment_hash=payment_hash, + locktime=swap.locktime, + refund_pubkey=their_pubkey, + claim_pubkey=our_pubkey, + ) + if swap.redeem_script != redeem_script: + raise ValueError("refund public key does not match swap contract") + if key in self.invoices_to_pay or self.wallet.get_invoice(invoice.get_id()) is not None: + raise ValueError("swap invoice is already queued or stored") self.wallet.save_invoice(invoice) + self.invoices_to_pay[key] = 0 return {} + def _get_provider_taproot_swap( + self, swap_id: object, *, direction: SwapDirection, + ) -> SwapData: + if not isinstance(swap_id, str) or len(swap_id) != 64: + raise ValueError("invalid Taproot swap ID") + with self.swaps_lock: + swap = self._swaps.get(swap_id) + if (swap is None or not swap.is_provider or not swap.is_cooperative + or swap.protocol != TAPROOT_SWAP_PROTOCOL): + raise ValueError("unknown provider Taproot swap") + contract = self._validate_taproot_swap(swap) + if contract.direction is not direction: + raise ValueError("Taproot swap direction does not match request") + return swap + + @staticmethod + def _parse_musig_session_id(session_id: object) -> bytes: + if not isinstance(session_id, str) or len(session_id) != 64: + raise ValueError("invalid MuSig session ID") + try: + session_id_bytes = bytes.fromhex(session_id) + except ValueError as e: + raise ValueError("invalid MuSig session ID") from e + if session_id != session_id_bytes.hex(): + raise ValueError("MuSig session ID must use canonical lowercase hex") + return session_id_bytes + + def _check_provider_cooperative_eligible( + self, swap: SwapData, *, direction: SwapDirection, preimage: Optional[bytes], + ) -> None: + if direction is SwapDirection.REVERSE: + if preimage is None or len(preimage) != 32 or sha256(preimage) != swap.payment_hash: + raise ValueError("cooperative claim preimage does not match swap") + return + if self.network.get_local_height() < swap.locktime: + raise ValueError("cooperative refund timeout has not been reached") + payment_status = self.lnworker.get_payment_status( + swap.payment_hash, direction=lnutil.SENT, + ) + inflight = getattr(self.lnworker, 'inflight_payments', ()) + get_payments = getattr(self.lnworker, 'get_payments', None) + channel_payments = get_payments(status='inflight') if get_payments else {} + if payment_status == PR_PAID: + raise ValueError("swap Lightning invoice has already been paid") + if (swap._payment_pending or swap._lightning_payment_pending + or payment_status == PR_INFLIGHT + or swap.payment_hash in inflight + or swap.payment_hash.hex() in inflight + or swap.payment_hash in channel_payments): + raise ValueError("swap Lightning payment is in flight") + + def _start_provider_cooperative_spend( + self, request: dict, *, direction: SwapDirection, + ) -> dict: + expected_fields = {'id', 'sessionId', 'transaction'} + if direction is SwapDirection.REVERSE: + expected_fields.add('preimage') + if set(request) != expected_fields: + raise ValueError("invalid cooperative spend request fields") + session_id = request.get('sessionId') + session_id_bytes = self._parse_musig_session_id(session_id) + swap = self._get_provider_taproot_swap(request.get('id'), direction=direction) + tx, msg32 = self._parse_cooperative_transaction( + swap, request.get('transaction'), require_confirmed=True, + ) + preimage = None + if direction is SwapDirection.REVERSE: + try: + preimage = bytes.fromhex(request['preimage']) + except (TypeError, ValueError) as e: + raise ValueError("invalid cooperative claim preimage") from e + now_monotonic = time.monotonic() + with self.swaps_lock: + self._check_provider_cooperative_eligible( + swap, direction=direction, preimage=preimage, + ) + self._musig_sessions = { + key: value for key, value in self._musig_sessions.items() + if value[0] > now_monotonic + } + if session_id in self._musig_sessions: + raise ValueError("MuSig session ID is already active") + if any(value[2] is swap for value in self._musig_sessions.values()): + raise ValueError("Taproot swap already has an active MuSig session") + if len(self._musig_sessions) >= MUSIG_SESSION_MAX_ENTRIES: + raise ValueError("MuSig session capacity reached") + signer = MuSig2Session.create( + contract=self.get_taproot_contract(swap), + local_seckey=swap.privkey, + msg32=msg32, + session_id32=session_id_bytes, + ) + self._musig_sessions[session_id] = ( + now_monotonic + MUSIG_SESSION_TTL_SEC, + direction, + swap, + tx.serialize_to_network(include_sigs=False), + msg32, + signer, + preimage, + ) + if direction is SwapDirection.FORWARD: + swap.refund_cancelled = True + self.invoices_to_pay.pop(swap.payment_hash.hex(), None) + return { + 'sessionId': session_id, + 'pubNonce': signer.public_nonce.hex(), + 'transaction': tx.serialize_to_network(include_sigs=False), + } + + def _finish_provider_cooperative_spend( + self, request: dict, *, direction: SwapDirection, + ) -> dict: + session_id = request.get('sessionId') + self._parse_musig_session_id(session_id) + with self.swaps_lock: + session = self._musig_sessions.pop(session_id, None) + if session is None: + raise ValueError("unknown or already used MuSig session") + if set(request) != {'id', 'sessionId', 'pubNonce', 'partialSignature'}: + raise ValueError("invalid cooperative signing request fields") + expires, session_direction, swap, unsigned_tx, msg32, signer, preimage = session + if time.monotonic() >= expires: + raise ValueError("MuSig session expired") + if session_direction is not direction or request.get('id') != swap.payment_hash.hex(): + raise ValueError("MuSig session does not match swap") + try: + client_nonce = bytes.fromhex(request['pubNonce']) + client_partial = bytes.fromhex(request['partialSignature']) + except (TypeError, ValueError) as e: + raise ValueError("invalid cooperative nonce or partial signature") from e + if len(client_nonce) != 66 or len(client_partial) != 32: + raise ValueError("invalid cooperative nonce or partial signature length") + with self.swaps_lock: + checked_tx, checked_msg32 = self._parse_cooperative_transaction( + swap, unsigned_tx, require_confirmed=True, + ) + self._check_provider_cooperative_eligible( + swap, direction=direction, preimage=preimage, + ) + if checked_msg32 != msg32: + raise ValueError("cooperative transaction identity changed while signing") + provider_partial = signer.sign_partial(client_nonce) + signature = signer.aggregate(client_partial) + checked_tx.inputs()[0].witness = construct_witness([signature]) + raw_signed_tx = checked_tx.serialize_to_network() + signed_tx = Transaction(raw_signed_tx) + if signed_tx.serialize_to_network(include_sigs=False) != unsigned_tx: + raise ValueError("cooperative transaction identity changed while signing") + self._verify_cooperative_witness( + swap, signed_tx, msg32=msg32, expected_signature=signature, + ) + swap.cooperative_tx = raw_signed_tx + swap.spending_txid = signed_tx.txid() + if direction is SwapDirection.REVERSE: + self.lnworker.save_preimage(swap.payment_hash, preimage, mark_as_public=True) + swap.preimage = preimage + if not self.wallet.adb.get_transaction(signed_tx.txid()): + try: + self.wallet.adb.add_transaction(signed_tx) + except Exception: + self.logger.exception("could not add cooperative swap transaction") + return { + 'sessionId': session_id, + 'partialSignature': provider_partial.hex(), + 'transaction': raw_signed_tx, + } + + def _server_cooperative_spend( + self, request: dict, *, direction: SwapDirection, + ) -> dict: + if type(request) is not dict: + raise ValueError("cooperative spend request must be an object") + handler = ( + self._finish_provider_cooperative_spend + if {'pubNonce', 'partialSignature'} & set(request) + else self._start_provider_cooperative_spend + ) + return handler(request, direction=direction) + + def server_claim_taproot_swap(self, request: dict) -> dict: + return self._server_cooperative_spend(request, direction=SwapDirection.REVERSE) + + def server_refund_taproot_swap(self, request: dict) -> dict: + return self._server_cooperative_spend(request, direction=SwapDirection.FORWARD) + async def normal_swap( self, *, @@ -989,38 +1689,73 @@ async def request_normal_swap( "invoiceAmount": lightning_amount_sat, "refundPublicKey": refund_pubkey.hex() } + use_taproot = TAPROOT_SWAP_PROTOCOL in transport.protocols + use_cooperative = TAPROOT_COOPERATIVE_CAPABILITY in transport.protocols + if use_taproot: + request_data['protocol'] = TAPROOT_SWAP_PROTOCOL data = await transport.send_request_to_server('createnormalswap', request_data) try: payment_hash = bytes.fromhex(data["preimageHash"]) - assert len(payment_hash) == 32, len(payment_hash) onchain_amount = data["expectedAmount"] - assert isinstance(onchain_amount, int), type(onchain_amount) locktime = data["timeoutBlockHeight"] - assert isinstance(locktime, int), type(locktime) lockup_address = data["address"] - assert isinstance(lockup_address, str), type(lockup_address) - assert bitcoin.is_address(lockup_address), lockup_address - redeem_script = bytes.fromhex(data["redeemScript"]) + if len(payment_hash) != 32: + raise ValueError("invalid payment hash") + if type(onchain_amount) is not int or onchain_amount < dust_threshold(): + raise ValueError("invalid onchain amount") + if type(locktime) is not int: + raise ValueError("invalid locktime") + if not isinstance(lockup_address, str) or not bitcoin.is_address(lockup_address): + raise ValueError("invalid lockup address") + if use_taproot: + if set(data) != { + 'id', 'protocol', 'acceptZeroConf', 'preimageHash', 'claimPublicKey', + 'timeoutBlockHeight', 'address', 'swapTree', 'expectedAmount', + }: + raise ValueError("Taproot provider returned an invalid response schema") + if data['protocol'] != TAPROOT_SWAP_PROTOCOL or data['acceptZeroConf'] is not False: + raise ValueError("provider returned invalid Taproot protocol semantics") + if data['id'] != payment_hash.hex(): + raise ValueError("Taproot swap ID does not match payment hash") + provider_pubkey = _parse_compressed_pubkey(data['claimPublicKey'], name="claim public key") + contract = TaprootSwapContract( + direction=SwapDirection.FORWARD, + payment_hash=payment_hash, + locktime=locktime, + provider_pubkey=provider_pubkey, + user_pubkey=refund_pubkey, + ) + contract.validate_provider_data(serialized_tree=data['swapTree'], address=lockup_address) + redeem_script = contract.output_script + else: + if any(field in data for field in ( + 'protocol', 'swapTree', 'claimPublicKey', 'refundPublicKey', + )): + raise ValueError("legacy provider returned unsolicited Taproot data") + provider_pubkey = None + redeem_script = bytes.fromhex(data["redeemScript"]) except Exception as e: self.logger.error(f"failed to parse response from swapserver for createnormalswap: {e!r}") raise SwapServerError("failed to parse response from swapserver for createnormalswap") from e del data # parsing done - # verify redeem_script is built with our pubkey and preimage - _check_swap_scriptcode( - redeem_script=redeem_script, - lockup_address=lockup_address, - payment_hash=payment_hash, - locktime=locktime, - refund_pubkey=refund_pubkey, - claim_pubkey=None, - ) + if not use_taproot: + _check_swap_scriptcode( + redeem_script=redeem_script, + lockup_address=lockup_address, + payment_hash=payment_hash, + locktime=locktime, + refund_pubkey=refund_pubkey, + claim_pubkey=None, + ) - # check that onchain_amount is not more than what we estimated + # Validate all provider-controlled semantics before creating the hold invoice. if onchain_amount > expected_onchain_amount_sat: raise Exception(f"fswap check failed: onchain_amount is more than what we estimated: " f"{onchain_amount} > {expected_onchain_amount_sat}") - # verify that they are not locking up funds for too long - if locktime - self.network.get_local_height() > MAX_LOCKTIME_DELTA: + locktime_delta = locktime - self.network.get_local_height() + if locktime_delta <= MIN_LOCKTIME_DELTA: + raise Exception("fswap check failed: locktime too close") + if locktime_delta > MAX_LOCKTIME_DELTA: raise Exception("fswap check failed: locktime too far in future") if self.network.blockchain().is_tip_stale(): raise Exception("our blockchain tip is stale") @@ -1034,6 +1769,9 @@ async def request_normal_swap( our_privkey=refund_privkey, prepay=False, channels=channels, + protocol=TAPROOT_SWAP_PROTOCOL if use_taproot else None, + their_pubkey=provider_pubkey, + is_cooperative=use_taproot and use_cooperative, # When the client is doing a normal swap, we create a ln-invoice with larger than usual final_cltv_delta. # If the user goes offline after broadcasting the funding tx (but before it is mined and # the server claims it), they need to come back online before the held ln-htlc expires (see #8940). @@ -1087,6 +1825,8 @@ async def lightning_payment_callback(_payment_hash): return swap.funding_txid def create_funding_output(self, swap: SwapData) -> PartialTxOutput: + if swap.protocol == TAPROOT_SWAP_PROTOCOL: + self._validate_taproot_swap(swap) return PartialTxOutput.from_address_and_value(swap.lockup_address, swap.onchain_amount) def create_funding_tx( @@ -1165,6 +1905,7 @@ async def reverse_swap( """ assert self.network assert self.lnwatcher + await self.is_initialized.wait() self._sanity_check_swap_costs( incoming_sat=expected_onchain_amount_sat, outgoing_sat=lightning_amount_sat) privkey = os.urandom(32) @@ -1178,36 +1919,73 @@ async def reverse_swap( "preimageHash": payment_hash.hex(), "claimPublicKey": our_pubkey.hex(), } + use_taproot = TAPROOT_SWAP_PROTOCOL in transport.protocols + use_cooperative = TAPROOT_COOPERATIVE_CAPABILITY in transport.protocols + if use_taproot: + request_data['protocol'] = TAPROOT_SWAP_PROTOCOL self.logger.debug(f'rswap: sending request for {lightning_amount_sat}') data = await transport.send_request_to_server('createswap', request_data) try: invoice = data['invoice'] - assert isinstance(invoice, str), type(invoice) fee_invoice = data.get('minerFeeInvoice') - assert fee_invoice is None or isinstance(fee_invoice, str), type(fee_invoice) lockup_address = data['lockupAddress'] - assert isinstance(lockup_address, str), type(lockup_address) - assert bitcoin.is_address(lockup_address), lockup_address - redeem_script = bytes.fromhex(data['redeemScript']) locktime = data['timeoutBlockHeight'] - assert isinstance(locktime, int), type(locktime) onchain_amount = data["onchainAmount"] - assert isinstance(onchain_amount, int), type(onchain_amount) response_id = data['id'] + if not isinstance(invoice, str): + raise ValueError("invalid swap invoice") + if fee_invoice is not None and not isinstance(fee_invoice, str): + raise ValueError("invalid miner fee invoice") + if not isinstance(lockup_address, str) or not bitcoin.is_address(lockup_address): + raise ValueError("invalid lockup address") + if type(locktime) is not int: + raise ValueError("invalid locktime") + if type(onchain_amount) is not int or onchain_amount < dust_threshold(): + raise ValueError("invalid onchain amount") + if response_id != payment_hash.hex(): + raise ValueError("swap ID does not match payment hash") + if use_taproot: + if set(data) != { + 'id', 'protocol', 'acceptZeroConf', 'invoice', 'minerFeeInvoice', + 'refundPublicKey', 'lockupAddress', 'swapTree', 'timeoutBlockHeight', + 'onchainAmount', + }: + raise ValueError("Taproot provider returned an invalid response schema") + if data['protocol'] != TAPROOT_SWAP_PROTOCOL or data['acceptZeroConf'] is not False: + raise ValueError("provider returned invalid Taproot protocol semantics") + provider_pubkey = _parse_compressed_pubkey(data['refundPublicKey'], name="refund public key") + contract = TaprootSwapContract( + direction=SwapDirection.REVERSE, + payment_hash=payment_hash, + locktime=locktime, + provider_pubkey=provider_pubkey, + user_pubkey=our_pubkey, + ) + contract.validate_provider_data(serialized_tree=data['swapTree'], address=lockup_address) + if not isinstance(fee_invoice, str): + raise ValueError("Taproot miner fee invoice is mandatory") + redeem_script = contract.output_script + else: + if any(field in data for field in ( + 'protocol', 'swapTree', 'claimPublicKey', 'refundPublicKey', + )): + raise ValueError("legacy provider returned unsolicited Taproot data") + provider_pubkey = None + redeem_script = bytes.fromhex(data['redeemScript']) except Exception as e: self.logger.error(f"failed to parse response from swapserver for createswap: {e!r}") raise SwapServerError("failed to parse response from swapserver for createswap") from e del data # parsing done self.logger.debug(f'rswap: {response_id=}') - # verify redeem_script is built with our pubkey and preimage - _check_swap_scriptcode( - redeem_script=redeem_script, - lockup_address=lockup_address, - payment_hash=payment_hash, - locktime=locktime, - refund_pubkey=None, - claim_pubkey=our_pubkey, - ) + if not use_taproot: + _check_swap_scriptcode( + redeem_script=redeem_script, + lockup_address=lockup_address, + payment_hash=payment_hash, + locktime=locktime, + refund_pubkey=None, + claim_pubkey=our_pubkey, + ) # check that the onchain amount is what we expected if onchain_amount < expected_onchain_amount_sat: raise Exception(f"rswap check failed: onchain_amount is less than what we expected: " @@ -1215,27 +1993,43 @@ async def reverse_swap( # verify that we will have enough time to get our tx confirmed if locktime - self.network.get_local_height() <= MIN_LOCKTIME_DELTA: raise Exception("rswap check failed: locktime too close") + if locktime - self.network.get_local_height() > MAX_LOCKTIME_DELTA: + raise Exception("rswap check failed: locktime too far in future") if self.network.blockchain().is_tip_stale(): raise Exception("our blockchain tip is stale") - # verify invoice payment_hash - lnaddr = self.lnworker._check_bolt11_invoice(invoice) - invoice_amount = int(lnaddr.get_amount_sat()) - if lnaddr.paymenthash != payment_hash: - raise Exception("rswap check failed: inconsistent RHASH and invoice") - if fee_invoice: - fee_lnaddr = self.lnworker._check_bolt11_invoice(fee_invoice) - if fee_lnaddr.get_amount_sat() > prepayment_sat: - raise SwapServerError(_("Mining fee requested by swap-server larger " - "than what was announced in their offer.")) - invoice_amount += fee_lnaddr.get_amount_sat() - prepay_hash = fee_lnaddr.paymenthash - else: - prepay_hash = None - # check that the lightning amount is what we requested - if int(invoice_amount) != lightning_amount_sat: - raise Exception(f"rswap check failed: invoice_amount ({invoice_amount}) " - f"not what we requested ({lightning_amount_sat})") - # save swap data to wallet file + try: + lnaddr = self.lnworker._check_bolt11_invoice(invoice) + invoice_amount = int(lnaddr.get_amount_sat()) + if lnaddr.paymenthash != payment_hash: + raise ValueError("main invoice payment hash does not match swap") + if fee_invoice: + fee_lnaddr = self.lnworker._check_bolt11_invoice(fee_invoice) + if fee_lnaddr.get_amount_sat() > prepayment_sat: + raise SwapServerError(_("Mining fee requested by swap-server larger " + "than what was announced in their offer.")) + invoice_amount += fee_lnaddr.get_amount_sat() + prepay_hash = fee_lnaddr.paymenthash + if prepay_hash == payment_hash: + raise ValueError("miner fee invoice reuses main payment hash") + else: + if prepayment_sat: + raise ValueError("mandatory miner fee invoice is missing") + prepay_hash = None + if int(invoice_amount) != lightning_amount_sat: + raise ValueError( + f"invoice amount ({invoice_amount}) does not match requested amount " + f"({lightning_amount_sat})" + ) + except SwapServerError: + raise + except Exception as e: + raise SwapServerError("failed to verify reverse swap invoices") from e + try: + invoice_obj = Invoice.from_bech32(invoice) + fee_invoice_obj = Invoice.from_bech32(fee_invoice) if fee_invoice else None + except Exception as e: + raise SwapServerError("failed to parse reverse swap invoices") from e + # Save only after the contract, amounts, locktime, and invoices all verify. swap = self.add_reverse_swap( redeem_script=redeem_script, locktime=locktime, @@ -1246,17 +2040,18 @@ async def reverse_swap( onchain_amount_sat=onchain_amount, lightning_amount_sat=lightning_amount_sat, claim_to_output=claim_to_output, + protocol=TAPROOT_SWAP_PROTOCOL if use_taproot else None, + their_pubkey=provider_pubkey, + is_cooperative=use_taproot and use_cooperative, ) # initiate fee payment. - if fee_invoice: - fee_invoice_obj = Invoice.from_bech32(fee_invoice) + if fee_invoice_obj: asyncio.ensure_future(self.lnworker.pay_invoice(fee_invoice_obj)) # we return if we detect funding async def wait_for_funding(swap): while swap.funding_txid is None: await asyncio.sleep(0.1) # initiate main payment - invoice_obj = Invoice.from_bech32(invoice) tasks = [asyncio.create_task(self.lnworker.pay_invoice(invoice_obj, channels=channels)), asyncio.create_task(wait_for_funding(swap))] await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) return swap.funding_txid @@ -1455,14 +2250,28 @@ def add_txin_info(cls, swap, txin: PartialTxInput) -> None: """Add some info to a claim txin. note: even without signing, this is useful for tx size estimation. """ + txin.script_sig = b'' + txin.nsequence = 1 if swap.is_reverse else 0xffffffff - 2 + if swap.protocol == TAPROOT_SWAP_PROTOCOL: + leaf = SwapLeaf.CLAIM if swap.is_reverse else SwapLeaf.REFUND + script, control_block = cls.get_taproot_contract(swap).script_path(leaf) + txin.tap_script_signing_data = TapScriptSigningData( + script=script, control_block=control_block, + ) + sig_dummy = bytes(64) + witness_items = ( + [sig_dummy, swap.preimage or bytes(32), script, control_block] + if swap.is_reverse + else [sig_dummy, script, control_block] + ) + txin.witness_sizehint = len(construct_witness(witness_items)) + return preimage = swap.preimage if swap.is_reverse else 0 witness_script = swap.redeem_script - txin.script_sig = b'' txin.witness_script = witness_script sig_dummy = b'\x00' * 71 # DER-encoded ECDSA sig, with low S and low R witness = [sig_dummy, preimage, witness_script] txin.witness_sizehint = len(construct_witness(witness)) - txin.nsequence = 1 if swap.is_reverse else 0xffffffff - 2 @classmethod def create_claim_txin( @@ -1478,11 +2287,22 @@ def create_claim_txin( locktime = swap.locktime cls.add_txin_info(swap, txin) txin.privkey = swap.privkey - def make_witness(sig): - # preimae not known yet - preimage = swap.preimage if swap.is_reverse else 0 - witness_script = swap.redeem_script - return construct_witness([sig, preimage, witness_script]) + if swap.protocol == TAPROOT_SWAP_PROTOCOL: + tap_script_data = txin.tap_script_signing_data + def make_witness(sig): + items = [sig] + if swap.is_reverse: + if swap.preimage is None or sha256(swap.preimage) != swap.payment_hash: + raise ValueError("Taproot claim preimage is unavailable") + items.append(swap.preimage) + items.extend((tap_script_data.script, tap_script_data.control_block)) + return construct_witness(items) + else: + def make_witness(sig): + # preimage not known yet + preimage = swap.preimage if swap.is_reverse else 0 + witness_script = swap.redeem_script + return construct_witness([sig, preimage, witness_script]) txin.make_witness = make_witness return txin, locktime @@ -1508,13 +2328,25 @@ def client_max_amount_reverse_swap(self) -> Optional[int]: def server_create_normal_swap(self, request): # normal for client, reverse for server - #request = await r.json() - lightning_amount_sat = request['invoiceAmount'] - their_pubkey = bytes.fromhex(request['refundPublicKey']) - assert len(their_pubkey) == 33 + if type(request) is not dict: + raise ValueError("normal swap request must be an object") + protocol = request.get('protocol') + if protocol not in (None, TAPROOT_SWAP_PROTOCOL): + raise ValueError("unsupported swap protocol") + try: + lightning_amount_sat = request['invoiceAmount'] + their_pubkey = _parse_compressed_pubkey( + request['refundPublicKey'], name="refund public key", + ) + except (KeyError, TypeError, ValueError) as e: + raise ValueError("invalid normal swap request") from e + if type(lightning_amount_sat) is not int or lightning_amount_sat <= 0: + raise ValueError("invalid invoice amount") + is_taproot = protocol == TAPROOT_SWAP_PROTOCOL swap = self.create_reverse_swap( lightning_amount_sat=lightning_amount_sat, their_pubkey=their_pubkey, + is_taproot=is_taproot, ) response = { "id": swap.payment_hash.hex(), @@ -1523,40 +2355,74 @@ def server_create_normal_swap(self, request): "expectedAmount": swap.onchain_amount, "timeoutBlockHeight": swap.locktime, "address": swap.lockup_address, - "redeemScript": swap.redeem_script.hex(), } + if is_taproot: + contract = self.get_taproot_contract(swap) + response.update({ + 'protocol': TAPROOT_SWAP_PROTOCOL, + 'claimPublicKey': contract.provider_pubkey.hex(), + 'swapTree': contract.serialized_tree(), + }) + else: + response['redeemScript'] = swap.redeem_script.hex() return response def server_create_swap(self, request): # reverse for client, forward for server - # requesting a normal swap (old protocol) will raise an exception - #request = await r.json() - req_type = request['type'] - assert request['pairId'] == 'BTC/BTC' + if type(request) is not dict: + raise ValueError("create swap request must be an object") + protocol = request.get('protocol') + if protocol not in (None, TAPROOT_SWAP_PROTOCOL): + raise ValueError("unsupported swap protocol") + try: + req_type = request['type'] + pair_id = request['pairId'] + except KeyError as e: + raise ValueError("invalid create swap request") from e + if pair_id != 'BTC/BTC': + raise ValueError("unsupported swap pair") if req_type == 'reversesubmarine': - lightning_amount_sat=request['invoiceAmount'] - payment_hash=bytes.fromhex(request['preimageHash']) - their_pubkey=bytes.fromhex(request['claimPublicKey']) - assert len(payment_hash) == 32 - assert len(their_pubkey) == 33 + try: + lightning_amount_sat = request['invoiceAmount'] + payment_hash = bytes.fromhex(request['preimageHash']) + their_pubkey = _parse_compressed_pubkey( + request['claimPublicKey'], name="claim public key", + ) + except (KeyError, TypeError, ValueError) as e: + raise ValueError("invalid reverse swap request") from e + if type(lightning_amount_sat) is not int or lightning_amount_sat <= 0: + raise ValueError("invalid invoice amount") + if len(payment_hash) != 32: + raise ValueError("invalid preimage hash") + is_taproot = protocol == TAPROOT_SWAP_PROTOCOL swap, invoice, prepay_invoice = self.create_normal_swap( lightning_amount_sat=lightning_amount_sat, payment_hash=payment_hash, - their_pubkey=their_pubkey + their_pubkey=their_pubkey, + is_taproot=is_taproot, ) response = { 'id': payment_hash.hex(), 'invoice': invoice, 'minerFeeInvoice': prepay_invoice, 'lockupAddress': swap.lockup_address, - 'redeemScript': swap.redeem_script.hex(), 'timeoutBlockHeight': swap.locktime, "onchainAmount": swap.onchain_amount, } + if is_taproot: + contract = self.get_taproot_contract(swap) + response.update({ + 'protocol': TAPROOT_SWAP_PROTOCOL, + 'acceptZeroConf': False, + 'refundPublicKey': contract.provider_pubkey.hex(), + 'swapTree': contract.serialized_tree(), + }) + else: + response['redeemScript'] = swap.redeem_script.hex() elif req_type == 'submarine': raise Exception('Deprecated API. Please upgrade your version of Electrum') else: - raise Exception('unsupported request type:' + req_type) + raise ValueError('unsupported request type:' + str(req_type)) return response def get_groups_for_onchain_history(self): @@ -1676,6 +2542,10 @@ async def send_request_to_server(self, method: str, request_data: Optional[dict] """Might raise SwapServerError.""" pass + @property + def protocols(self) -> Tuple[str, ...]: + return () + @property def uses_proxy(self): return self.network.proxy and self.network.proxy.enabled @@ -1711,8 +2581,13 @@ class HttpTransport(SwapServerTransport): def __init__(self, config, sm): SwapServerTransport.__init__(self, config=config, sm=sm) self.api_url = config.SWAPSERVER_URL + self._protocols = () self.is_connected.set() + @property + def protocols(self) -> Tuple[str, ...]: + return self._protocols + def __enter__(self): asyncio.run_coroutine_threadsafe(self.get_pairs_just_once(), self.network.asyncio_loop) return self @@ -1750,7 +2625,9 @@ async def get_pairs_just_once(self) -> None: """Might raise SwapServerError.""" response = await self.send_request_to_server('getpairs', None) try: - assert response.get('htlcFirst') is True + if response.get('htlcFirst') is not True: + raise ValueError("swap server does not use HTLC-first swaps") + protocols = _parse_swap_protocols(response.get('protocols', [])) fees = response['pairs']['BTC/BTC']['fees'] limits = response['pairs']['BTC/BTC']['limits'] pairs = SwapFees( @@ -1763,6 +2640,7 @@ async def get_pairs_just_once(self) -> None: except Exception as e: self.logger.error(f"failed to parse response from swapserver for getpairs: {e!r}") raise SwapServerError("failed to parse response from swapserver for getpairs") from e + self._protocols = protocols self.sm.update_pairs(pairs) @@ -1888,6 +2766,11 @@ def get_recent_offers(self) -> Sequence[SwapOffer]: recent_offers = recent_offers[:20] return recent_offers + @property + def protocols(self) -> Tuple[str, ...]: + offer = self._offers.get(self.config.SWAPSERVER_NPUB) + return offer.protocols if offer is not None else () + @ignore_exceptions @log_exceptions async def publish_offer(self, sm: 'SwapManager') -> None: @@ -1902,6 +2785,7 @@ async def publish_offer(self, sm: 'SwapManager') -> None: 'max_forward_amount': sm._max_forward, 'max_reverse_amount': sm._max_reverse, 'relays': sm.config.NOSTR_RELAYS, + 'protocols': [TAPROOT_SWAP_PROTOCOL, TAPROOT_COOPERATIVE_CAPABILITY], 'pow_nonce': hex(sm.config.SWAPSERVER_ANN_POW_NONCE), } # the first value of a single letter tag is indexed and can be filtered for @@ -1953,6 +2837,8 @@ async def send_request_to_server(self, method: str, request_data: dict) -> dict: self.dm_replies[(server_pubkey, event_id)] = fut = asyncio.Future() response = await fut assert isinstance(response, dict) + response = dict(response) + response.pop('reply_to', None) if 'error' in response: self.logger.warning(f"error from swap server [DO NOT TRUST THIS MESSAGE]: {response['error']}") raise SwapServerError() @@ -1998,6 +2884,7 @@ async def _get_pairs_loop(self): continue try: server_relays = content['relays'].split(',') + protocols = _parse_swap_protocols(content.get('protocols', [])) except Exception: continue try: @@ -2017,6 +2904,7 @@ async def _get_pairs_loop(self): timestamp=event.created_at, server_pubkey=pubkey, pow_bits=pow_bits, + protocols=protocols, ) self._offers[offer.server_npub] = offer if self.config.SWAPSERVER_NPUB == offer.server_npub: @@ -2107,6 +2995,10 @@ async def _handle_requests(self) -> None: r = self.sm.server_create_swap(request) elif method == 'createnormalswap': # client-forward-swap phase1 r = self.sm.server_create_normal_swap(request) + elif method == 'claimtaprootswap': + r = self.sm.server_claim_taproot_swap(request) + elif method == 'refundtaprootswap': + r = self.sm.server_refund_taproot_swap(request) else: raise Exception(method) r['reply_to'] = event_id diff --git a/electrum/taproot_swaps.py b/electrum/taproot_swaps.py new file mode 100644 index 000000000000..ee74ba9e484e --- /dev/null +++ b/electrum/taproot_swaps.py @@ -0,0 +1,401 @@ +# Copyright (C) 2026 The Electrum developers +# Distributed under the MIT software license, see the accompanying +# file LICENCE or http://www.opensource.org/licenses/mit-license.php +"""Provider-neutral Taproot swap contracts and cooperative signing.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +from electrum_ecc import ECPrivkey, ECPubkey, InvalidECPointException, musig + +from .bitcoin import ( + LEAF_VERSION_TAPSCRIPT, + NLOCKTIME_BLOCKHEIGHT_MAX, + TapTree, + construct_script, + control_block_for_taproot_script_spend, + opcodes, + script_num_to_bytes, + script_to_address, + taproot_output_script, + taproot_tree_helper, + taproot_tweak_hash, +) +from .crypto import ripemd + + +__all__ = ( + "MuSig2Session", + "SwapDirection", + "SwapLeaf", + "TaprootSwapContract", +) + +_SESSION_INTERNAL = object() + + +class SwapDirection(Enum): + """Swap direction from the user's perspective.""" + + FORWARD = "forward" # user funds, provider claims, user refunds + REVERSE = "reverse" # provider funds, user claims, provider refunds + + +class SwapLeaf(Enum): + CLAIM = "claim" + REFUND = "refund" + + +def _require_bytes(value: object, *, name: str, length: int) -> bytes: + if not isinstance(value, bytes): + raise TypeError(f"{name} must be bytes") + if len(value) != length: + raise ValueError(f"{name} must be {length} bytes") + return value + + +def _validate_pubkey(pubkey: object, *, name: str) -> bytes: + pubkey = _require_bytes(pubkey, name=name, length=33) + if pubkey[0] not in (0x02, 0x03): + raise ValueError(f"{name} must be a compressed public key") + try: + ECPubkey(pubkey) + except InvalidECPointException as e: + raise ValueError(f"{name} is not a valid public key") from e + return pubkey + + +@dataclass(frozen=True) +class TaprootSwapContract: + """Canonical two-leaf, single-provider Taproot swap contract. + + MuSig key aggregation is always ordered provider first, user second. Leaf + ownership follows :class:`SwapDirection` and cannot be supplied separately. + """ + + direction: SwapDirection + payment_hash: bytes + locktime: int + provider_pubkey: bytes + user_pubkey: bytes + + def __post_init__(self) -> None: + if type(self.direction) is not SwapDirection: + raise TypeError("direction must be a SwapDirection") + _require_bytes(self.payment_hash, name="payment_hash", length=32) + if type(self.locktime) is not int: + raise TypeError("locktime must be int") + if not 0 < self.locktime <= NLOCKTIME_BLOCKHEIGHT_MAX: + raise ValueError("locktime must be a positive block height") + _validate_pubkey(self.provider_pubkey, name="provider_pubkey") + _validate_pubkey(self.user_pubkey, name="user_pubkey") + if self.provider_pubkey[1:] == self.user_pubkey[1:]: + raise ValueError("provider and user x-only keys must be distinct") + + @property + def claim_pubkey(self) -> bytes: + if self.direction is SwapDirection.FORWARD: + return self.provider_pubkey + return self.user_pubkey + + @property + def refund_pubkey(self) -> bytes: + if self.direction is SwapDirection.FORWARD: + return self.user_pubkey + return self.provider_pubkey + + @property + def claim_leaf_script(self) -> bytes: + items = [] + if self.direction is SwapDirection.REVERSE: + items.extend((opcodes.OP_SIZE, 32, opcodes.OP_EQUALVERIFY)) + items.extend(( + opcodes.OP_HASH160, + ripemd(self.payment_hash), + opcodes.OP_EQUALVERIFY, + self.claim_pubkey[1:], + opcodes.OP_CHECKSIG, + )) + return construct_script(items) + + @property + def refund_leaf_script(self) -> bytes: + return construct_script(( + self.refund_pubkey[1:], + opcodes.OP_CHECKSIGVERIFY, + script_num_to_bytes(self.locktime), + opcodes.OP_CHECKLOCKTIMEVERIFY, + )) + + @property + def tap_tree(self) -> TapTree: + return [ + (LEAF_VERSION_TAPSCRIPT, self.claim_leaf_script), + (LEAF_VERSION_TAPSCRIPT, self.refund_leaf_script), + ] + + @property + def merkle_root(self) -> bytes: + _, root = taproot_tree_helper(self.tap_tree) + return root + + def _keyagg_cache(self) -> musig.KeyAggCache: + return musig.KeyAggCache.from_pubkeys( + [self.provider_pubkey, self.user_pubkey] + ) + + @property + def internal_pubkey(self) -> bytes: + return self._keyagg_cache().aggregate_xonly_pubkey() + + @property + def output_script(self) -> bytes: + return taproot_output_script( + self.internal_pubkey, script_tree=self.tap_tree + ) + + def address(self, *, net=None) -> str: + address = script_to_address(self.output_script, net=net) + if address is None: + raise RuntimeError("could not derive Taproot swap address") + return address + + def script_path(self, leaf: SwapLeaf) -> tuple[bytes, bytes]: + """Return the leaf script and control block for a unilateral spend.""" + if type(leaf) is not SwapLeaf: + raise TypeError("leaf must be a SwapLeaf") + return control_block_for_taproot_script_spend( + internal_pubkey=self.internal_pubkey, + script_tree=self.tap_tree, + script_num=0 if leaf is SwapLeaf.CLAIM else 1, + ) + + def serialized_tree(self) -> dict[str, dict[str, int | str]]: + return { + "claimLeaf": { + "version": LEAF_VERSION_TAPSCRIPT, + "output": self.claim_leaf_script.hex(), + }, + "refundLeaf": { + "version": LEAF_VERSION_TAPSCRIPT, + "output": self.refund_leaf_script.hex(), + }, + } + + def validate_provider_data( + self, + *, + serialized_tree: object, + address: str, + net=None, + ) -> None: + """Validate provider data against this contract's full semantics.""" + if type(serialized_tree) is not dict: + raise ValueError("swap tree must be an object") + expected_scripts = { + "claimLeaf": self.claim_leaf_script, + "refundLeaf": self.refund_leaf_script, + } + if set(serialized_tree) != set(expected_scripts): + raise ValueError( + "swap tree must contain only claimLeaf and refundLeaf" + ) + for name, expected_script in expected_scripts.items(): + leaf = serialized_tree[name] + if type(leaf) is not dict: + raise ValueError(f"{name} must be an object") + if set(leaf) != {"version", "output"}: + raise ValueError( + f"{name} must contain only version and output" + ) + if leaf["version"] != LEAF_VERSION_TAPSCRIPT: + raise ValueError(f"{name} has an unsupported tapleaf version") + output = leaf["output"] + if not isinstance(output, str): + raise ValueError(f"{name} output must be hex") + if len(output) > 20_000: + raise ValueError(f"{name} script exceeds 10,000 bytes") + try: + script = bytes.fromhex(output) + except ValueError as e: + raise ValueError(f"{name} output must be hex") from e + if script != expected_script: + raise ValueError( + f"{name} does not match the expected contract" + ) + + if not isinstance(address, str): + raise TypeError("address must be str") + if address != self.address(net=net): + raise ValueError( + "swap address does not match the expected contract" + ) + + +class MuSig2Session: + """One participant's single-message cooperative MuSig2 signing session.""" + + def __init__( + self, + *, + contract: TaprootSwapContract, + local_seckey: bytes, + local_pubkey: bytes, + counterparty_pubkey: bytes, + msg32: bytes, + keyagg_cache: musig.KeyAggCache, + secnonce: musig.SecNonce, + pubnonce: musig.PubNonce, + _token: object = None, + ) -> None: + if _token is not _SESSION_INTERNAL: + raise TypeError( + "MuSig2Session instances must be created with create" + ) + self._contract = contract + self._local_seckey: Optional[bytes] = local_seckey + self._local_pubkey = local_pubkey + self._counterparty_pubkey = counterparty_pubkey + self._msg32 = msg32 + self._cache = keyagg_cache + self._secnonce: Optional[musig.SecNonce] = secnonce + self._pubnonce = pubnonce + self._counterparty_pubnonce: Optional[musig.PubNonce] = None + self._session: Optional[musig.Session] = None + self._local_partial_sig: Optional[musig.PartialSig] = None + self._aggregate_signature: Optional[bytes] = None + + @classmethod + def create( + cls, + *, + contract: TaprootSwapContract, + local_seckey: bytes, + msg32: bytes, + session_id32: Optional[bytes] = None, + ) -> "MuSig2Session": + if type(contract) is not TaprootSwapContract: + raise TypeError("contract must be a TaprootSwapContract") + local_seckey = _require_bytes( + local_seckey, name="local_seckey", length=32 + ) + msg32 = _require_bytes(msg32, name="msg32", length=32) + if session_id32 is not None: + session_id32 = _require_bytes( + session_id32, name="session_id32", length=32 + ) + try: + local_pubkey = ECPrivkey(local_seckey).get_public_key_bytes( + compressed=True + ) + except InvalidECPointException as e: + raise ValueError( + "local_seckey is not a valid secret scalar" + ) from e + if local_pubkey == contract.provider_pubkey: + counterparty_pubkey = contract.user_pubkey + elif local_pubkey == contract.user_pubkey: + counterparty_pubkey = contract.provider_pubkey + else: + raise ValueError( + "local_seckey does not belong to the swap contract" + ) + + cache = contract._keyagg_cache() + cache.apply_xonly_tweak( + taproot_tweak_hash( + cache.aggregate_xonly_pubkey(), contract.merkle_root + ) + ) + secnonce, pubnonce = musig.nonce_gen( + pubkey=local_pubkey, + seckey=local_seckey, + msg32=msg32, + keyagg_cache=cache, + extra_input32=session_id32, + ) + return cls( + contract=contract, + local_seckey=local_seckey, + local_pubkey=local_pubkey, + counterparty_pubkey=counterparty_pubkey, + msg32=msg32, + keyagg_cache=cache, + secnonce=secnonce, + pubnonce=pubnonce, + _token=_SESSION_INTERNAL, + ) + + @property + def public_nonce(self) -> bytes: + return self._pubnonce.to_bytes() + + def sign_partial(self, counterparty_public_nonce: bytes) -> bytes: + """Create one partial signature and irrevocably consume the nonce.""" + if self._session is not None: + raise RuntimeError("this MuSig2 session has already signed") + counterparty = musig.PubNonce(counterparty_public_nonce) + if self._local_pubkey == self._contract.provider_pubkey: + pubnonces = [self._pubnonce, counterparty] + else: + pubnonces = [counterparty, self._pubnonce] + session = musig.nonce_process( + aggnonce=musig.nonce_agg(pubnonces), + msg32=self._msg32, + keyagg_cache=self._cache, + ) + self._counterparty_pubnonce = counterparty + self._session = session + secnonce, self._secnonce = self._secnonce, None + local_seckey, self._local_seckey = self._local_seckey, None + assert secnonce is not None + assert local_seckey is not None + try: + partial_sig = musig.partial_sign( + secnonce=secnonce, + seckey=local_seckey, + keyagg_cache=self._cache, + session=session, + ) + finally: + del secnonce, local_seckey + self._local_partial_sig = partial_sig + return partial_sig.to_bytes() + + def aggregate(self, counterparty_partial_signature: bytes) -> bytes: + """Verify and aggregate the two partials into a BIP340 signature.""" + if self._aggregate_signature is not None: + raise RuntimeError("this MuSig2 session has already aggregated") + if ( + self._session is None + or self._counterparty_pubnonce is None + or self._local_partial_sig is None + ): + raise RuntimeError("sign_partial must be called before aggregate") + counterparty_partial = musig.PartialSig(counterparty_partial_signature) + if not musig.partial_sig_verify( + partial_sig=counterparty_partial, + pubnonce=self._counterparty_pubnonce, + pubkey=self._counterparty_pubkey, + keyagg_cache=self._cache, + session=self._session, + ): + raise ValueError("counterparty partial signature is invalid") + + if self._local_pubkey == self._contract.provider_pubkey: + partials = [self._local_partial_sig, counterparty_partial] + else: + partials = [counterparty_partial, self._local_partial_sig] + signature = musig.partial_sig_agg( + session=self._session, partial_sigs=partials + ) + output_pubkey = ECPubkey(b"\x02" + self._contract.output_script[2:]) + if not output_pubkey.schnorr_verify(signature, self._msg32): + raise ValueError( + "aggregate signature does not verify for the swap output" + ) + self._aggregate_signature = signature + return signature diff --git a/electrum/transaction.py b/electrum/transaction.py index 46b9351c765d..d7f1adce3956 100644 --- a/electrum/transaction.py +++ b/electrum/transaction.py @@ -47,7 +47,7 @@ from .bitcoin import ( TYPE_ADDRESS, TYPE_SCRIPT, hash_160, hash160_to_p2sh, hash160_to_p2pkh, hash_to_segwit_addr, var_int, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC, COIN, opcodes, base_decode, base_encode, construct_witness, construct_script, - taproot_tweak_seckey + LEAF_VERSION_TAPSCRIPT, tapleaf_hash, taproot_tweak_pubkey, taproot_tweak_seckey, witness_push, ) from .crypto import sha256d, sha256 from .logging import get_logger @@ -128,6 +128,65 @@ def to_sigbytes(cls, sighash: int) -> bytes: return sighash.to_bytes(length=1, byteorder="big") +class TapScriptSigningData(NamedTuple): + """Ephemeral BIP342 signing data. This is deliberately not serialized to PSBT.""" + + script: bytes + control_block: bytes + annex: Optional[bytes] = None + key_version: int = 0 + codesep_pos: int = 0xFFFFFFFF + + def validate(self, *, scriptpubkey: bytes) -> bytes: + if not isinstance(self.script, bytes): + raise TypeError("tapscript must be bytes") + if not isinstance(self.control_block, bytes): + raise TypeError("taproot control block must be bytes") + if len(self.control_block) < 33 or (len(self.control_block) - 33) % 32: + raise ValueError("invalid taproot control block length") + if (len(self.control_block) - 33) // 32 > 128: + raise ValueError("taproot control block Merkle path is too deep") + leaf_version = self.control_block[0] & 0xFE + if leaf_version != LEAF_VERSION_TAPSCRIPT: + raise ValueError("unsupported tapscript leaf version") + if type(self.key_version) is not int or self.key_version != 0: + raise ValueError("invalid tapscript key version") + if type(self.codesep_pos) is not int or not 0 <= self.codesep_pos <= 0xFFFFFFFF: + raise ValueError("invalid tapscript code-separator position") + if self.codesep_pos != 0xFFFFFFFF: + try: + opcode = next( + opcode for pos, (opcode, _data, _end) in enumerate(script_GetOp(self.script)) + if pos == self.codesep_pos + ) + except (MalformedBitcoinScript, StopIteration) as e: + raise ValueError("invalid tapscript code-separator position") from e + if opcode != opcodes.OP_CODESEPARATOR: + raise ValueError("tapscript code-separator position does not reference OP_CODESEPARATOR") + if self.annex is not None: + if not isinstance(self.annex, bytes): + raise TypeError("taproot annex must be bytes") + if not self.annex or self.annex[0] != 0x50: + raise ValueError("taproot annex must start with 0x50") + if (not isinstance(scriptpubkey, bytes) + or len(scriptpubkey) != 34 + or scriptpubkey[:2] != b"\x51\x20"): + raise ValueError("tapscript signing requires a P2TR scriptPubKey") + + leaf_hash = tapleaf_hash(leaf_version=leaf_version, script=self.script) + root = leaf_hash + for pos in range(33, len(self.control_block), 32): + sibling = self.control_block[pos:pos + 32] + root = bip340_tagged_hash(b"TapBranch", min(root, sibling) + max(root, sibling)) + try: + parity, output_key = taproot_tweak_pubkey(self.control_block[1:33], root) + except (ValueError, ecc.InvalidECPointException) as e: + raise ValueError("invalid taproot control block internal key") from e + if parity != (self.control_block[0] & 1) or output_key != scriptpubkey[2:]: + raise ValueError("taproot control block does not match scriptPubKey") + return leaf_hash + + class TxOutput: scriptpubkey: bytes value: Union[int, str] @@ -347,6 +406,7 @@ def __init__(self, *, self.__value_sats = None # type: Optional[int] self._is_taproot = None # type: Optional[bool] # None means unknown + self.tap_script_signing_data = None # type: Optional[TapScriptSigningData] def get_time_based_relative_locktime(self) -> Optional[int]: # see bip 68 @@ -1074,7 +1134,29 @@ def serialize_preimage( sighash_cache = SighashCache() if txin.is_segwit(): if txin.is_taproot(): + tap_script_data = txin.tap_script_signing_data + if tap_script_data is not None: + # The existing BIP341 cache eagerly computes all shared fields. + for required_txin in inputs: + prevout = required_txin.prevout + if (not isinstance(prevout, TxOutpoint) + or not isinstance(prevout.txid, bytes) or len(prevout.txid) != 32 + or type(prevout.out_idx) is not int or not 0 <= prevout.out_idx <= 0xFFFFFFFF): + raise ValueError("missing or invalid taproot input outpoint") + value = required_txin.value_sats() + if type(value) is not int or not 0 <= value <= TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN: + raise ValueError("missing or invalid taproot input value") + if not isinstance(required_txin.scriptpubkey, bytes): + raise ValueError("missing or invalid taproot input scriptPubKey") + if type(required_txin.nsequence) is not int or not 0 <= required_txin.nsequence <= 0xFFFFFFFF: + raise ValueError("invalid taproot input sequence") + scriptpubkey = txin.scriptpubkey + if not isinstance(scriptpubkey, bytes): + raise ValueError("missing or invalid taproot input scriptPubKey") + tapleaf = tap_script_data.validate(scriptpubkey=scriptpubkey) scache = sighash_cache.get_witver1_data_for_tx(self) + ext_flag = 1 if tap_script_data is not None else 0 + annex = tap_script_data.annex if tap_script_data is not None else None sighash_epoch = b"\x00" hash_type = int.to_bytes(sighash, length=1, byteorder="little", signed=False) # txdata @@ -1090,7 +1172,7 @@ def serialize_preimage( preimage_txdata += scache.sha_outputs # inputdata preimage_inputdata = bytearray() - spend_type = bytes([0]) # (ext_flag * 2) + annex_present + spend_type = bytes([ext_flag * 2 + (annex is not None)]) preimage_inputdata += spend_type if sighash & 0x80 == Sighash.ANYONECANPAY: preimage_inputdata += txin.prevout.serialize_to_network() @@ -1099,7 +1181,8 @@ def serialize_preimage( preimage_inputdata += int.to_bytes(txin.nsequence, length=4, byteorder="little", signed=False) else: preimage_inputdata += int.to_bytes(txin_index, length=4, byteorder="little", signed=False) - # TODO sha_annex + if annex is not None: + preimage_inputdata += sha256(witness_push(annex)) # outputdata preimage_outputdata = bytearray() if sighash & 3 == Sighash.SINGLE: @@ -1109,7 +1192,13 @@ def serialize_preimage( raise Exception("Using SIGHASH_SINGLE without a corresponding output") from None # note: we could cache this to avoid some potential DOS vectors: preimage_outputdata += sha256(txout.serialize_to_network()) - return bytes(sighash_epoch + hash_type + preimage_txdata + preimage_inputdata + preimage_outputdata) + preimage_ext = bytearray() + if tap_script_data is not None: + preimage_ext += tapleaf + preimage_ext += bytes([tap_script_data.key_version]) + preimage_ext += tap_script_data.codesep_pos.to_bytes(4, byteorder="little") + return bytes(sighash_epoch + hash_type + preimage_txdata + preimage_inputdata + + preimage_outputdata + preimage_ext) else: # segwit (witness v0) scache = sighash_cache.get_witver0_data_for_tx(self) if not (sighash & Sighash.ANYONECANPAY): @@ -1742,6 +1831,7 @@ def from_txin(cls, txin: TxInput, *, strip_witness: bool = True) -> 'PartialTxIn witness=None if strip_witness else txin.witness, is_coinbase_output=txin.is_coinbase_output()) res.utxo = txin.utxo + res.tap_script_signing_data = txin.tap_script_signing_data return res def validate_data( @@ -2497,14 +2587,31 @@ def sign_txin( *, sighash_cache: SighashCache = None, ) -> bytes: - txin = self.inputs()[txin_index] + inputs = self.inputs() + if type(txin_index) is not int or not 0 <= txin_index < len(inputs): + raise ValueError(f"invalid transaction input index: {txin_index!r}") + txin = inputs[txin_index] txin.validate_data(for_signing=True) + if txin.tap_script_signing_data is not None: + sighash = txin.sighash if txin.sighash is not None else Sighash.DEFAULT + if not Sighash.is_valid(sighash, is_taproot=True): + raise ValueError(f"SIGHASH_FLAG ({sighash}) not supported!") + value = txin.value_sats() + if type(value) is not int or not 0 <= value <= TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN: + raise ValueError("missing or invalid taproot input value") + if not isinstance(txin.scriptpubkey, bytes): + raise ValueError("missing or invalid taproot input scriptPubKey") + if txin.is_taproot() is not True: + raise ValueError("tapscript signing requires a P2TR input") pre_hash = self.serialize_preimage(txin_index, sighash_cache=sighash_cache) if txin.is_taproot(): - # note: privkey_bytes is the internal key - merkle_root = txin.tap_merkle_root or bytes() - output_privkey_bytes = taproot_tweak_seckey(privkey_bytes, merkle_root) - output_privkey = ecc.ECPrivkey(output_privkey_bytes) + if txin.tap_script_signing_data is not None: + output_privkey = ecc.ECPrivkey(privkey_bytes) + else: + # note: privkey_bytes is the internal key + merkle_root = txin.tap_merkle_root or bytes() + output_privkey_bytes = taproot_tweak_seckey(privkey_bytes, merkle_root) + output_privkey = ecc.ECPrivkey(output_privkey_bytes) msg_hash = bip340_tagged_hash(b"TapSighash", pre_hash) sig = output_privkey.schnorr_sign(msg_hash) sighash = txin.sighash if txin.sighash is not None else Sighash.DEFAULT diff --git a/electrum/txbatcher.py b/electrum/txbatcher.py index 48279b40e0e0..d9241d13ffac 100644 --- a/electrum/txbatcher.py +++ b/electrum/txbatcher.py @@ -522,6 +522,7 @@ def add_sweep_info_to_tx(self, base_tx: PartialTransaction) -> None: txin.make_witness = sweep_info.txin.make_witness txin.privkey = sweep_info.txin.privkey txin.witness_script = sweep_info.txin.witness_script + txin.tap_script_signing_data = sweep_info.txin.tap_script_signing_data txin.script_sig = sweep_info.txin.script_sig def _create_batch_tx( diff --git a/tests/regtest.py b/tests/regtest.py index 9c2de7b36243..4e79e5882d8a 100644 --- a/tests/regtest.py +++ b/tests/regtest.py @@ -107,6 +107,12 @@ def test_swapserver_success_forward(self): def test_swapserver_success_reverse(self): self.run_shell(['swapserver_success_reverse']) + def test_swapserver_taproot_forward(self): + self.run_shell(['swapserver_taproot_forward'], timeout=180) + + def test_swapserver_taproot_reverse(self): + self.run_shell(['swapserver_taproot_reverse'], timeout=180) + def test_swapserver_forceclose(self): self.run_shell(['swapserver_forceclose']) diff --git a/tests/regtest/regtest.sh b/tests/regtest/regtest.sh index ced9f0b083eb..92bdbcd98c50 100755 --- a/tests/regtest/regtest.sh +++ b/tests/regtest/regtest.sh @@ -362,6 +362,63 @@ if [[ $1 == "swapserver_success_forward" ]]; then fi +if [[ $1 == "swapserver_taproot_reverse" ]]; then + wait_for_balance alice 1 + echo "alice opens channel" + bob_node=$($bob nodeid) + channel=$($alice open_channel "$bob_node" 0.15 --password='') + new_blocks 3 + wait_until_channel_open alice + echo "alice initiates taproot reverse-swap" + dryrun=$($alice reverse_swap 0.02 dryrun) + onchain_amount=$(jq -r ".onchain_amount" <<< "$dryrun") + prepayment=$(jq -r ".prepayment" <<< "$dryrun") + swap=$($alice reverse_swap 0.02 "$onchain_amount" --prepayment "$prepayment") + printf '%s\n' "$swap" | jq + funding_txid=$(jq -r ".funding_txid" <<< "$swap") + assert_utxo_exists "$funding_txid" 0 + new_blocks 1 + wait_until_spent "$funding_txid" 0 + spending_txid=$($bitcoin_cli gettxspendingprevout \ + "[{\"txid\":\"$funding_txid\",\"vout\":0}]" | jq -r '.[0].spendingtxid') + witness_items=$($bitcoin_cli getrawtransaction "$spending_txid" true | \ + jq '.vin[0].txinwitness | length') + if [[ "$witness_items" != "1" ]]; then + printf "expected cooperative Taproot key-path claim, got %s witness items\n" "$witness_items" + exit 1 + fi + wait_until_htlcs_settled alice +fi + + +if [[ $1 == "swapserver_taproot_forward" ]]; then + wait_for_balance alice 1 + echo "alice opens channel" + bob_node=$($bob nodeid) + channel=$($alice open_channel "$bob_node" 0.15 --password='' --push_amount=0.075) + new_blocks 3 + wait_until_channel_open alice + echo "alice initiates taproot forward-swap" + dryrun=$($alice normal_swap 0.02 dryrun) + lightning_amount=$(jq -r ".lightning_amount" <<< "$dryrun") + swap=$($alice normal_swap 0.02 "$lightning_amount") + printf '%s\n' "$swap" | jq + funding_txid=$(jq -r ".txid" <<< "$swap") + assert_utxo_exists "$funding_txid" 0 + new_blocks 1 + wait_until_spent "$funding_txid" 0 + spending_txid=$($bitcoin_cli gettxspendingprevout \ + "[{\"txid\":\"$funding_txid\",\"vout\":0}]" | jq -r '.[0].spendingtxid') + witness_items=$($bitcoin_cli getrawtransaction "$spending_txid" true | \ + jq '.vin[0].txinwitness | length') + if [[ "$witness_items" != "4" ]]; then + printf "expected Taproot claim-leaf spend, got %s witness items\n" "$witness_items" + exit 1 + fi + wait_until_htlcs_settled bob +fi + + if [[ $1 == "swapserver_forceclose" ]]; then # Alice starts reverse-swap with Bob. # Alice sends hold-HTLCs via LN, Bob funds locking script onchain. diff --git a/tests/test_bitcoin.py b/tests/test_bitcoin.py index 78a6b523d96a..40fb331d26d4 100644 --- a/tests/test_bitcoin.py +++ b/tests/test_bitcoin.py @@ -6,6 +6,7 @@ import inspect import electrum_ecc as ecc +from electrum_ecc.util import bip340_tagged_hash from electrum import bitcoin from electrum.bitcoin import (public_key_to_p2pkh, address_from_private_key, @@ -16,7 +17,7 @@ is_compressed_privkey, EncodeBase58Check, DecodeBase58Check, script_num_to_bytes, push_script, add_number_to_script, opcodes, base_encode, base_decode, BitcoinException, - taproot_tweak_pubkey, taproot_tweak_seckey, taproot_output_script, + tapleaf_hash, taproot_tweak_hash, taproot_tweak_pubkey, taproot_tweak_seckey, taproot_output_script, control_block_for_taproot_script_spend) from electrum import bip32 from electrum import segwit_addr @@ -1167,6 +1168,33 @@ def test_base58check(self): class TestTaprootHelpers(ElectrumTestCase): + def test_taproot_tweak_hash_preserves_helper_assertions(self): + pubkey32 = bytes.fromhex("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") + self.assertEqual( + taproot_tweak_hash(pubkey32, b""), + bip340_tagged_hash(b"TapTweak", pubkey32), + ) + for pubkey, merkle_root in ( + (bytearray(pubkey32), b""), + (pubkey32, bytearray()), + (bytes(31), b""), + ): + with self.subTest(pubkey=pubkey, merkle_root=merkle_root): + with self.assertRaises(AssertionError): + taproot_tweak_hash(pubkey, merkle_root) + + def test_tapleaf_hash_validates_leaf_versions(self): + for leaf_version in (0x00, 0x66, 0xC0, 0xFE): + with self.subTest(leaf_version=leaf_version): + self.assertEqual(32, len(tapleaf_hash(leaf_version=leaf_version, script=b""))) + for leaf_version in (-2, 0x50, 0xC1, 0x100): + with self.subTest(leaf_version=leaf_version), self.assertRaises(ValueError): + tapleaf_hash(leaf_version=leaf_version, script=b"") + with self.assertRaises(TypeError): + tapleaf_hash(leaf_version=True, script=b"") + with self.assertRaises(TypeError): + tapleaf_hash(leaf_version=0xC0, script=bytearray()) + def test_taproot_tweak_homomorphism(self): # For any byte string h it holds that # taproot_tweak_pubkey(pubkey_gen(seckey), h)[1] == pubkey_gen(taproot_tweak_seckey(seckey, h)). @@ -1202,6 +1230,10 @@ def flatten_tree(tree_node): self.assertEqual(tcase["expected"]["bip350Address"], bitcoin.script_to_address(spk)) if script_tree: flat_tree = flatten_tree(script_tree) + self.assertEqual( + tcase["intermediary"]["leafHashes"], + [tapleaf_hash(leaf_version=v, script=s).hex() for v, s in flat_tree], + ) for script_num, jcontrol_block in enumerate(tcase["expected"]["scriptPathControlBlocks"]): leaf_script, control_block = control_block_for_taproot_script_spend( internal_pubkey=internal_pubkey, script_tree=script_tree, script_num=script_num) diff --git a/tests/test_submarine_swaps_taproot.py b/tests/test_submarine_swaps_taproot.py new file mode 100644 index 000000000000..34512d97756d --- /dev/null +++ b/tests/test_submarine_swaps_taproot.py @@ -0,0 +1,1472 @@ +import asyncio +import json +import logging +import threading +import time +from decimal import Decimal +from types import SimpleNamespace +from unittest import mock + +from electrum_ecc import ECPrivkey + +from electrum import bitcoin +from electrum.address_synchronizer import TX_HEIGHT_LOCAL +from electrum.crypto import sha256 +from electrum.invoices import Invoice, PR_INFLIGHT, PR_PAID, PR_UNPAID +from electrum.plugins.swapserver.server import HttpSwapServer +from electrum.submarine_swaps import ( + COOPERATIVE_SWAP_TX_SIZE, + MUSIG_SESSION_MAX_ENTRIES, + MUSIG_SESSION_TTL_SEC, + TAPROOT_COOPERATIVE_CAPABILITY, + TAPROOT_SWAP_PROTOCOL, + HttpTransport, + NostrTransport, + SwapData, + SwapFees, + SwapManager, + SwapOffer, + SwapServerError, + _construct_swap_scriptcode, +) +from electrum.taproot_swaps import MuSig2Session, SwapDirection, SwapLeaf, TaprootSwapContract +from electrum.transaction import ( + PartialTransaction, + PartialTxInput, + PartialTxOutput, + Transaction, + TxOutpoint, + TxOutput, +) +from electrum.util import MyEncoder + +from . import ElectrumTestCase + + +PROVIDER_SECKEY = bytes.fromhex( + "b7e151628aed2a6abf7158809cf4f3c762e7160f38b4da56a784d9045190cfef" +) +USER_SECKEY = bytes.fromhex( + "c90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b14e5c9" +) +PROVIDER_PUBKEY = ECPrivkey(PROVIDER_SECKEY).get_public_key_bytes(compressed=True) +USER_PUBKEY = ECPrivkey(USER_SECKEY).get_public_key_bytes(compressed=True) +PREIMAGE = b"\xaa" * 32 +PAYMENT_HASH = sha256(PREIMAGE) +LOCKTIME = 800_000 +LOCAL_HEIGHT = LOCKTIME - 70 +ONCHAIN_AMOUNT = 1_000_000 +LIGHTNING_AMOUNT = 1_001_000 +FEE = 500 + + +def destination_address() -> str: + pubkey = ECPrivkey(b"\x42" * 32).get_public_key_bytes(compressed=True).hex() + return bitcoin.pubkey_to_address("p2wpkh", pubkey) + + +def alternate_destination_address() -> str: + pubkey = ECPrivkey(b"\x43" * 32).get_public_key_bytes(compressed=True).hex() + return bitcoin.pubkey_to_address("p2wpkh", pubkey) + + +def contract(direction: SwapDirection, *, payment_hash=PAYMENT_HASH) -> TaprootSwapContract: + return TaprootSwapContract( + direction=direction, + payment_hash=payment_hash, + locktime=LOCKTIME, + provider_pubkey=PROVIDER_PUBKEY, + user_pubkey=USER_PUBKEY, + ) + + +def make_swap( + *, direction: SwapDirection, is_provider: bool, is_cooperative: bool = True, +) -> SwapData: + swap_contract = contract(direction) + is_reverse = (direction is SwapDirection.REVERSE) ^ is_provider + swap = SwapData( + is_reverse=is_reverse, + locktime=LOCKTIME, + onchain_amount=ONCHAIN_AMOUNT, + lightning_amount=LIGHTNING_AMOUNT, + redeem_script=swap_contract.output_script, + preimage=PREIMAGE if is_reverse else None, + prepay_hash=None, + privkey=PROVIDER_SECKEY if is_provider else USER_SECKEY, + lockup_address=swap_contract.address(), + claim_to_output=None, + funding_txid=None, + spending_txid=None, + is_redeemed=False, + protocol=TAPROOT_SWAP_PROTOCOL, + their_pubkey=USER_PUBKEY if is_provider else PROVIDER_PUBKEY, + is_provider=is_provider, + is_cooperative=is_cooperative, + ) + swap._payment_hash = PAYMENT_HASH + return swap + + +def make_funding(swap: SwapData, *, amount=ONCHAIN_AMOUNT, scriptpubkey=None): + source = PartialTxInput(prevout=TxOutpoint(txid=b"\x33" * 32, out_idx=0)) + source.script_sig = b"" + source.nsequence = 0xffffffff + source.witness = bitcoin.construct_witness([b"funding"]) + funding_txout = TxOutput( + scriptpubkey=swap.redeem_script if scriptpubkey is None else scriptpubkey, + value=amount, + ) + funding = PartialTransaction.from_io( + [source], [PartialTxOutput.from_txout(funding_txout)], version=2, BIP69_sort=False, + ) + funding_tx = Transaction(funding.serialize_to_network()) + swap.funding_txid = funding_tx.txid() + prevout = TxOutpoint(txid=bytes.fromhex(swap.funding_txid), out_idx=0) + swap._funding_prevout = prevout + watched = PartialTxInput(prevout=prevout) + watched._trusted_address = swap.lockup_address + watched._trusted_value_sats = amount + watched.spent_height = None + watched.spent_txid = None + return funding_tx, funding_txout, watched + + +def logger(): + return logging.getLogger("taproot-swap-integration-test") + + +def manager_for_watcher( + swap: SwapData, *, confirmed=True, amount=ONCHAIN_AMOUNT, scriptpubkey=None, +): + funding_tx, funding_txout, watched = make_funding( + swap, amount=amount, scriptpubkey=scriptpubkey, + ) + swap.funding_txid = None + swap._funding_prevout = None + tx_height = SimpleNamespace(conf=1 if confirmed else 0) + adb = SimpleNamespace( + get_addr_outputs=lambda _address: {watched.prevout: watched}, + get_transaction=lambda txid: funding_tx if txid == funding_tx.txid() else None, + get_tx_height=lambda _txid: tx_height, + is_up_to_date=lambda: True, + ) + manager = SwapManager.__new__(SwapManager) + manager.logger = logger() + manager.swaps_lock = threading.Lock() + manager._swaps = {swap.payment_hash.hex(): swap} + manager._swaps_by_funding_outpoint = {watched.prevout: swap} + manager._swaps_by_lockup_address = {swap.lockup_address: swap} + manager._prepayments = {} + manager.invoices_to_pay = {swap.payment_hash.hex(): 0} + manager.lnwatcher = SimpleNamespace(adb=adb, remove_callback=mock.Mock()) + manager.lnworker = SimpleNamespace( + get_preimage=mock.Mock(return_value=swap.preimage), + save_preimage=mock.Mock(), + hold_invoice_callbacks={}, + ) + manager.network = SimpleNamespace( + get_local_height=lambda: LOCKTIME - 1, + config=SimpleNamespace(TEST_SWAPSERVER_REFUND=False), + ) + manager.wallet = SimpleNamespace( + txbatcher=SimpleNamespace(add_sweep_input=mock.Mock()), + ) + manager.config = SimpleNamespace(FEE_POLICY_SWAPS="fixed:1") + manager._add_or_reindex_swap = mock.Mock() + async def discard_task(coro): + coro.close() + manager.taskgroup = SimpleNamespace(spawn=mock.AsyncMock(side_effect=discard_task)) + return manager, funding_txout, watched + + +def manager_for_cooperation( + swap: SwapData, *, confirmed=True, height=LOCKTIME, +): + funding_tx, funding_txout, watched = make_funding(swap) + tx_height = SimpleNamespace(conf=1 if confirmed else 0) + transactions = {funding_tx.txid(): funding_tx} + + def add_transaction(tx): + transactions[tx.txid()] = tx + return True + + adb = SimpleNamespace( + get_addr_outputs=lambda _address: {watched.prevout: watched}, + get_transaction=lambda txid: transactions.get(txid), + get_tx_height=lambda _txid: tx_height, + add_transaction=mock.Mock(side_effect=add_transaction), + is_up_to_date=lambda: True, + ) + manager = SwapManager.__new__(SwapManager) + manager.logger = logger() + manager.swaps_lock = threading.Lock() + manager._swaps = {swap.payment_hash.hex(): swap} + manager._swaps_by_funding_outpoint = {watched.prevout: swap} + manager._swaps_by_lockup_address = {swap.lockup_address: swap} + manager._prepayments = {} + manager._musig_sessions = {} + manager.invoices_to_pay = {swap.payment_hash.hex(): 0} + manager.lnwatcher = SimpleNamespace(adb=adb, remove_callback=mock.Mock()) + manager.lnworker = SimpleNamespace( + get_preimage=mock.Mock(return_value=swap.preimage), + save_preimage=mock.Mock(), + get_payment_status=mock.Mock(return_value=PR_UNPAID), + get_payments=mock.Mock(return_value={}), + inflight_payments=set(), + hold_invoice_callbacks={}, + ) + manager.network = SimpleNamespace( + get_local_height=lambda: height, + broadcast_transaction=mock.AsyncMock(), + config=SimpleNamespace(TEST_SWAPSERVER_REFUND=False), + ) + manager.wallet = SimpleNamespace( + get_receiving_address=destination_address, + adb=adb, + txbatcher=SimpleNamespace(add_sweep_input=mock.Mock()), + ) + manager.config = SimpleNamespace(FEE_POLICY_SWAPS="fixed:1") + manager.get_fee_for_txbatcher = lambda: FEE + manager._add_or_reindex_swap = mock.Mock() + async def discard_task(coro): + coro.close() + manager.taskgroup = SimpleNamespace(spawn=mock.AsyncMock(side_effect=discard_task)) + return manager, funding_txout, watched + + +def unsigned_cooperative_tx( + watched: PartialTxInput, + funding_txout: TxOutput, + *, + destination=None, + value=ONCHAIN_AMOUNT - FEE, +) -> PartialTransaction: + txin = PartialTxInput(prevout=watched.prevout) + txin.witness_utxo = funding_txout + txin.script_sig = b"" + txin.nsequence = 0xffffffff - 2 + return PartialTransaction.from_io( + [txin], + [PartialTxOutput.from_address_and_value( + destination or destination_address(), value, + )], + locktime=0, + version=2, + BIP69_sort=False, + ) + + +def copy_txin(txin: PartialTxInput) -> PartialTxInput: + copied = PartialTxInput(prevout=txin.prevout) + copied.script_sig = txin.script_sig + copied.nsequence = txin.nsequence + return copied + + +class TestCapabilities(ElectrumTestCase): + async def test_http_capability_and_missing_field_legacy_fallback(self): + response = { + "htlcFirst": True, + "protocols": [TAPROOT_SWAP_PROTOCOL, TAPROOT_COOPERATIVE_CAPABILITY], + "pairs": {"BTC/BTC": { + "fees": { + "percentage": 0.5, + "minerFees": {"baseAsset": {"mining_fee": 500}}, + }, + "limits": { + "minimal": 20_000, + "max_forward_amount": 1_000_000, + "max_reverse_amount": 1_000_000, + }, + }}, + } + sm = SimpleNamespace(update_pairs=mock.Mock()) + transport = SimpleNamespace( + send_request_to_server=mock.AsyncMock(return_value=response), + sm=sm, + logger=logger(), + _protocols=(), + ) + await HttpTransport.get_pairs_just_once(transport) + self.assertEqual( + (TAPROOT_SWAP_PROTOCOL, TAPROOT_COOPERATIVE_CAPABILITY), + transport._protocols, + ) + self.assertIsInstance(sm.update_pairs.call_args.args[0], SwapFees) + + response.pop("protocols") + await HttpTransport.get_pairs_just_once(transport) + self.assertEqual((), transport._protocols) + + async def test_http_server_and_nostr_offer_advertise_capability(self): + sm = SimpleNamespace( + server_update_pairs=mock.Mock(), + _max_forward=1_000_000, + _max_reverse=1_000_000, + _min_amount=20_000, + percentage=Decimal("0.5"), + mining_fee=500, + is_server=True, + server_claim_taproot_swap=mock.Mock(return_value={"claim": True}), + server_refund_taproot_swap=mock.Mock(return_value={"refund": True}), + config=SimpleNamespace( + NOSTR_RELAYS="wss://relay.example", SWAPSERVER_ANN_POW_NONCE=0, + ), + ) + response = await HttpSwapServer.get_pairs(SimpleNamespace(sm=sm), None) + self.assertEqual( + [TAPROOT_SWAP_PROTOCOL, TAPROOT_COOPERATIVE_CAPABILITY], + json.loads(response.body)["protocols"], + ) + request = SimpleNamespace(json=mock.AsyncMock(return_value={"id": "swap"})) + claim = await HttpSwapServer.claim_taproot_swap(SimpleNamespace(sm=sm), request) + refund = await HttpSwapServer.refund_taproot_swap(SimpleNamespace(sm=sm), request) + self.assertEqual({"claim": True}, json.loads(claim.body)) + self.assertEqual({"refund": True}, json.loads(refund.body)) + + transport = SimpleNamespace( + sm=sm, + relay_manager=object(), + nostr_private_key="nsec-test", + logger=logger(), + USER_STATUS_NIP38=NostrTransport.USER_STATUS_NIP38, + NOSTR_EVENT_VERSION=NostrTransport.NOSTR_EVENT_VERSION, + OFFER_UPDATE_INTERVAL_SEC=NostrTransport.OFFER_UPDATE_INTERVAL_SEC, + ) + with mock.patch( + "electrum.submarine_swaps.aionostr._add_event", + new=mock.AsyncMock(return_value="event-id"), + ) as publish: + await NostrTransport.publish_offer(transport, sm) + self.assertEqual( + [TAPROOT_SWAP_PROTOCOL, TAPROOT_COOPERATIVE_CAPABILITY], + json.loads(publish.await_args.kwargs["content"])["protocols"], + ) + + legacy_offer = SwapOffer( + pairs=mock.Mock(), relays=[], pow_bits=0, server_pubkey="11" * 32, + timestamp=0, + ) + self.assertEqual((), legacy_offer.protocols) + + async def test_nostr_transport_removes_reply_envelope_before_schema_validation(self): + transport = SimpleNamespace( + config=SimpleNamespace(SWAPSERVER_NPUB="npub-test"), + dm_replies={}, + logger=logger(), + relays=(), + send_direct_message=mock.AsyncMock(return_value="event-id"), + ) + with mock.patch( + "electrum.submarine_swaps.aionostr.util.from_nip19", + return_value={"object": bytes.fromhex("11" * 32)}, + ): + request = asyncio.create_task(NostrTransport.send_request_to_server( + transport, "createswap", {"value": 1}, + )) + await asyncio.sleep(0) + transport.dm_replies[("11" * 32, "event-id")].set_result({ + "reply_to": "event-id", "result": True, + }) + response = await request + self.assertEqual({"result": True}, response) + + +class TestNegotiation(ElectrumTestCase): + @staticmethod + def creation_manager(): + manager = SwapManager.__new__(SwapManager) + manager.logger = logger() + manager.network = SimpleNamespace( + get_local_height=lambda: LOCAL_HEIGHT, + blockchain=lambda: SimpleNamespace(is_tip_stale=lambda: False), + ) + manager.swaps_lock = threading.Lock() + manager._swaps = {} + manager._swaps_by_funding_outpoint = {} + manager._swaps_by_lockup_address = {} + manager._prepayments = {} + manager.mining_fee = FEE + stored_swaps = {} + manager.wallet = SimpleNamespace( + db=SimpleNamespace(get_dict=lambda _key: stored_swaps), + get_receiving_address=destination_address, + ) + manager.lnwatcher = SimpleNamespace() + manager.add_lnwatcher_callback = mock.Mock() + manager._sanity_check_swap_costs = mock.Mock() + manager.is_initialized = asyncio.Event() + manager.is_initialized.set() + lnaddr = SimpleNamespace(get_min_final_cltv_delta=lambda: 432) + manager.lnworker = SimpleNamespace( + add_payment_info_for_hold_invoice=mock.Mock(), + get_payment_info=lambda payment_hash, direction: SimpleNamespace( + payment_hash=payment_hash), + get_bolt11_invoice=lambda **kwargs: ( + lnaddr, "lnfake:" + kwargs["payment_info"].payment_hash.hex()), + ) + return manager + + @staticmethod + def forward_response(request): + user_pubkey = bytes.fromhex(request["refundPublicKey"]) + response_contract = TaprootSwapContract( + direction=SwapDirection.FORWARD, + payment_hash=PAYMENT_HASH, + locktime=LOCKTIME, + provider_pubkey=PROVIDER_PUBKEY, + user_pubkey=user_pubkey, + ) + return { + "id": PAYMENT_HASH.hex(), + "protocol": TAPROOT_SWAP_PROTOCOL, + "acceptZeroConf": False, + "preimageHash": PAYMENT_HASH.hex(), + "claimPublicKey": PROVIDER_PUBKEY.hex(), + "timeoutBlockHeight": LOCKTIME, + "address": response_contract.address(), + "swapTree": response_contract.serialized_tree(), + "expectedAmount": ONCHAIN_AMOUNT, + } + + async def test_forward_contract_verified_and_persisted(self): + manager = self.creation_manager() + + async def send(_method, request): + self.assertEqual(TAPROOT_SWAP_PROTOCOL, request["protocol"]) + return self.forward_response(request) + + swap, _invoice = await manager.request_normal_swap( + transport=SimpleNamespace( + protocols=(TAPROOT_SWAP_PROTOCOL, TAPROOT_COOPERATIVE_CAPABILITY), + send_request_to_server=send, + ), + lightning_amount_sat=LIGHTNING_AMOUNT, + expected_onchain_amount_sat=ONCHAIN_AMOUNT, + ) + self.assertEqual(TAPROOT_SWAP_PROTOCOL, swap.protocol) + self.assertEqual(PROVIDER_PUBKEY, swap.their_pubkey) + self.assertTrue(swap.is_cooperative) + restored = manager.get_taproot_contract(swap) + self.assertIs(SwapDirection.FORWARD, restored.direction) + self.assertEqual(PROVIDER_PUBKEY, restored.provider_pubkey) + self.assertEqual(swap.redeem_script, restored.output_script) + self.assertIs(swap, manager._swaps[PAYMENT_HASH.hex()]) + + manager = self.creation_manager() + + branch_a_swap, _invoice = await manager.request_normal_swap( + transport=SimpleNamespace( + protocols=(TAPROOT_SWAP_PROTOCOL,), send_request_to_server=send, + ), + lightning_amount_sat=LIGHTNING_AMOUNT, + expected_onchain_amount_sat=ONCHAIN_AMOUNT, + ) + self.assertFalse(branch_a_swap.is_cooperative) + + async def test_forward_tampering_rejected_before_persistence(self): + for tamper in ( + "tree", "protocol", "zero-conf", "mixed-schema", "extra-field", "id", "key", + "address", "amount", "locktime", + ): + with self.subTest(tamper=tamper): + manager = self.creation_manager() + + async def send(_method, request, *, tamper=tamper): + response = self.forward_response(request) + if tamper == "tree": + response["swapTree"]["claimLeaf"]["output"] = "00" + elif tamper == "protocol": + response["protocol"] = None + elif tamper == "zero-conf": + response["acceptZeroConf"] = True + elif tamper == "mixed-schema": + response["redeemScript"] = "00" + elif tamper == "extra-field": + response["unknown"] = None + elif tamper == "id": + response["id"] = "00" * 32 + elif tamper == "key": + response["claimPublicKey"] = USER_PUBKEY.hex() + elif tamper == "address": + response["address"] = contract(SwapDirection.REVERSE).address() + elif tamper == "amount": + response["expectedAmount"] = ONCHAIN_AMOUNT + 1 + elif tamper == "locktime": + response["timeoutBlockHeight"] += 1 + return response + + error_context = ( + self.assertRaisesRegex(Exception, "fswap check failed") + if tamper == "amount" else self.assertRaises(SwapServerError) + ) + with error_context: + await manager.request_normal_swap( + transport=SimpleNamespace( + protocols=(TAPROOT_SWAP_PROTOCOL, TAPROOT_COOPERATIVE_CAPABILITY), + send_request_to_server=send, + ), + lightning_amount_sat=LIGHTNING_AMOUNT, + expected_onchain_amount_sat=ONCHAIN_AMOUNT, + ) + self.assertEqual({}, manager._swaps) + + async def test_forward_locktime_rejected_before_hold_invoice_creation(self): + manager = self.creation_manager() + + async def send(_method, request): + response = self.forward_response(request) + locktime = LOCAL_HEIGHT + 1 + response_contract = TaprootSwapContract( + direction=SwapDirection.FORWARD, + payment_hash=PAYMENT_HASH, + locktime=locktime, + provider_pubkey=PROVIDER_PUBKEY, + user_pubkey=bytes.fromhex(request["refundPublicKey"]), + ) + response.update({ + "timeoutBlockHeight": locktime, + "address": response_contract.address(), + "swapTree": response_contract.serialized_tree(), + }) + return response + + with self.assertRaisesRegex(Exception, "locktime too close"): + await manager.request_normal_swap( + transport=SimpleNamespace( + protocols=(TAPROOT_SWAP_PROTOCOL,), send_request_to_server=send, + ), + lightning_amount_sat=LIGHTNING_AMOUNT, + expected_onchain_amount_sat=ONCHAIN_AMOUNT, + ) + manager.lnworker.add_payment_info_for_hold_invoice.assert_not_called() + self.assertEqual({}, manager._swaps) + + async def test_legacy_schema_fallback_and_unsolicited_taproot_rejection(self): + manager = self.creation_manager() + + async def send(_method, request): + self.assertNotIn("protocol", request) + user_pubkey = bytes.fromhex(request["refundPublicKey"]) + script = _construct_swap_scriptcode( + payment_hash=PAYMENT_HASH, + locktime=LOCKTIME, + refund_pubkey=user_pubkey, + claim_pubkey=PROVIDER_PUBKEY, + ) + return { + "preimageHash": PAYMENT_HASH.hex(), + "expectedAmount": ONCHAIN_AMOUNT, + "timeoutBlockHeight": LOCKTIME, + "address": bitcoin.script_to_p2wsh(script), + "redeemScript": script.hex(), + } + + swap, _invoice = await manager.request_normal_swap( + transport=SimpleNamespace(protocols=(), send_request_to_server=send), + lightning_amount_sat=LIGHTNING_AMOUNT, + expected_onchain_amount_sat=ONCHAIN_AMOUNT, + ) + self.assertIsNone(swap.protocol) + self.assertIsNone(swap.their_pubkey) + + manager = self.creation_manager() + + async def unsolicited(_method, request): + response = await send(_method, request) + response["swapTree"] = {} + return response + + with self.assertRaises(SwapServerError): + await manager.request_normal_swap( + transport=SimpleNamespace(protocols=(), send_request_to_server=unsolicited), + lightning_amount_sat=LIGHTNING_AMOUNT, + expected_onchain_amount_sat=ONCHAIN_AMOUNT, + ) + self.assertEqual({}, manager._swaps) + + async def test_reverse_contract_and_mandatory_prepay_verify_before_persistence(self): + for tamper in ( + None, "tree", "missing-prepay", "same-hash", "zero-conf", "extra-field", + ): + with self.subTest(tamper=tamper): + manager = self.creation_manager() + fee_hash = b"\xbb" * 32 + main_hash = None + + def check_invoice(encoded, *, tamper=tamper, fee_hash=fee_hash): + nonlocal main_hash + if encoded == "fee-invoice": + return SimpleNamespace( + paymenthash=main_hash if tamper == "same-hash" else fee_hash, + get_amount_sat=lambda: 1_000, + ) + main_hash = bytes.fromhex(encoded.split(":")[1]) + return SimpleNamespace( + paymenthash=main_hash, + get_amount_sat=lambda: LIGHTNING_AMOUNT - 1_000, + ) + + async def pay_invoice(_invoice, manager=manager, **_kwargs): + if manager._swaps: + next(iter(manager._swaps.values())).funding_txid = "11" * 32 + return True, [] + + manager.lnworker._check_bolt11_invoice = check_invoice + manager.lnworker.pay_invoice = pay_invoice + + async def send(_method, request, *, tamper=tamper): + payment_hash = bytes.fromhex(request["preimageHash"]) + user_pubkey = bytes.fromhex(request["claimPublicKey"]) + response_contract = TaprootSwapContract( + direction=SwapDirection.REVERSE, + payment_hash=payment_hash, + locktime=LOCKTIME, + provider_pubkey=PROVIDER_PUBKEY, + user_pubkey=user_pubkey, + ) + response = { + "id": payment_hash.hex(), + "protocol": TAPROOT_SWAP_PROTOCOL, + "acceptZeroConf": False, + "invoice": "lnfake:" + payment_hash.hex(), + "minerFeeInvoice": "fee-invoice", + "refundPublicKey": PROVIDER_PUBKEY.hex(), + "lockupAddress": response_contract.address(), + "swapTree": response_contract.serialized_tree(), + "timeoutBlockHeight": LOCKTIME, + "onchainAmount": ONCHAIN_AMOUNT, + } + if tamper == "tree": + response["swapTree"]["refundLeaf"]["output"] = "00" + elif tamper == "missing-prepay": + response["minerFeeInvoice"] = None + elif tamper == "zero-conf": + response["acceptZeroConf"] = True + elif tamper == "extra-field": + response["unknown"] = None + return response + + with mock.patch.object(Invoice, "from_bech32", return_value=SimpleNamespace()): + if tamper is None: + await manager.reverse_swap( + transport=SimpleNamespace( + protocols=(TAPROOT_SWAP_PROTOCOL, TAPROOT_COOPERATIVE_CAPABILITY), + send_request_to_server=send, + ), + lightning_amount_sat=LIGHTNING_AMOUNT, + expected_onchain_amount_sat=ONCHAIN_AMOUNT, + prepayment_sat=1_000, + ) + [swap] = manager._swaps.values() + self.assertEqual(fee_hash, swap.prepay_hash) + restored = manager.get_taproot_contract(swap) + self.assertIs(SwapDirection.REVERSE, restored.direction) + self.assertEqual(PROVIDER_PUBKEY, restored.provider_pubkey) + self.assertEqual(swap.redeem_script, restored.output_script) + self.assertTrue(swap.is_cooperative) + else: + with self.assertRaises(SwapServerError): + await manager.reverse_swap( + transport=SimpleNamespace( + protocols=(TAPROOT_SWAP_PROTOCOL, TAPROOT_COOPERATIVE_CAPABILITY), + send_request_to_server=send, + ), + lightning_amount_sat=LIGHTNING_AMOUNT, + expected_onchain_amount_sat=ONCHAIN_AMOUNT, + prepayment_sat=1_000, + ) + self.assertEqual({}, manager._swaps) + await asyncio.sleep(0) + + async def test_reverse_legacy_response_downgrade(self): + manager = self.creation_manager() + fee_hash = b"\xbb" * 32 + + def check_invoice(encoded): + payment_hash = bytes.fromhex(encoded.split(":")[1]) if encoded.startswith("main:") else fee_hash + amount = LIGHTNING_AMOUNT - 1_000 if encoded.startswith("main:") else 1_000 + return SimpleNamespace(paymenthash=payment_hash, get_amount_sat=lambda: amount) + + async def send(_method, request): + self.assertNotIn("protocol", request) + payment_hash = bytes.fromhex(request["preimageHash"]) + script = _construct_swap_scriptcode( + payment_hash=payment_hash, + locktime=LOCKTIME, + refund_pubkey=PROVIDER_PUBKEY, + claim_pubkey=bytes.fromhex(request["claimPublicKey"]), + ) + return { + "id": payment_hash.hex(), + "invoice": "main:" + payment_hash.hex(), + "minerFeeInvoice": "fee", + "lockupAddress": bitcoin.script_to_p2wsh(script), + "redeemScript": script.hex(), + "timeoutBlockHeight": LOCKTIME, + "onchainAmount": ONCHAIN_AMOUNT, + } + + async def pay_invoice(_invoice, **_kwargs): + next(iter(manager._swaps.values())).funding_txid = "11" * 32 + return True, [] + + manager.lnworker._check_bolt11_invoice = check_invoice + manager.lnworker.pay_invoice = pay_invoice + with mock.patch.object(Invoice, "from_bech32", return_value=SimpleNamespace()): + await manager.reverse_swap( + transport=SimpleNamespace(protocols=(), send_request_to_server=send), + lightning_amount_sat=LIGHTNING_AMOUNT, + expected_onchain_amount_sat=ONCHAIN_AMOUNT, + prepayment_sat=1_000, + ) + [swap] = manager._swaps.values() + self.assertIsNone(swap.protocol) + self.assertIsNone(swap.their_pubkey) + await asyncio.sleep(0) + + def test_provider_creation_schemas_for_both_directions(self): + actual_manager = self.creation_manager() + actual_manager._get_send_amount = mock.Mock(return_value=ONCHAIN_AMOUNT) + created = actual_manager.create_reverse_swap( + lightning_amount_sat=LIGHTNING_AMOUNT, + their_pubkey=USER_PUBKEY, + is_taproot=True, + ) + self.assertTrue(created.is_cooperative) + + manager = SwapManager.__new__(SwapManager) + forward = make_swap(direction=SwapDirection.FORWARD, is_provider=True) + manager.create_reverse_swap = mock.Mock(return_value=forward) + response = manager.server_create_normal_swap({ + "protocol": TAPROOT_SWAP_PROTOCOL, + "invoiceAmount": LIGHTNING_AMOUNT, + "refundPublicKey": USER_PUBKEY.hex(), + }) + self.assertEqual(TAPROOT_SWAP_PROTOCOL, response["protocol"]) + self.assertNotIn("redeemScript", response) + self.assertFalse(response["acceptZeroConf"]) + + self.assertNotIn("refundAddress", response) + self.assertNotIn("refundValue", response) + + reverse = make_swap(direction=SwapDirection.REVERSE, is_provider=True) + manager.create_normal_swap = mock.Mock(return_value=(reverse, "main", "prepay")) + response = manager.server_create_swap({ + "protocol": TAPROOT_SWAP_PROTOCOL, + "type": "reversesubmarine", + "pairId": "BTC/BTC", + "invoiceAmount": LIGHTNING_AMOUNT, + "preimageHash": PAYMENT_HASH.hex(), + "claimPublicKey": USER_PUBKEY.hex(), + }) + self.assertEqual("prepay", response["minerFeeInvoice"]) + self.assertFalse(response["acceptZeroConf"]) + self.assertNotIn("redeemScript", response) + + self.assertNotIn("claimAddress", response) + self.assertNotIn("claimValue", response) + + legacy_script = _construct_swap_scriptcode( + payment_hash=PAYMENT_HASH, + locktime=LOCKTIME, + refund_pubkey=PROVIDER_PUBKEY, + claim_pubkey=USER_PUBKEY, + ) + reverse.protocol = None + reverse.their_pubkey = None + reverse.redeem_script = legacy_script + reverse.lockup_address = bitcoin.script_to_p2wsh(legacy_script) + manager.create_normal_swap = mock.Mock(return_value=(reverse, "main", "prepay")) + response = manager.server_create_swap({ + "type": "reversesubmarine", + "pairId": "BTC/BTC", + "invoiceAmount": LIGHTNING_AMOUNT, + "preimageHash": PAYMENT_HASH.hex(), + "claimPublicKey": USER_PUBKEY.hex(), + }) + self.assertEqual(legacy_script.hex(), response["redeemScript"]) + self.assertNotIn("protocol", response) + self.assertNotIn("swapTree", response) + + +class TestPersistenceInvoicesAndSettlement(ElectrumTestCase): + def test_contract_roles_survive_reload_and_tampering_fails(self): + for direction in SwapDirection: + for is_provider in (False, True): + with self.subTest(direction=direction, is_provider=is_provider): + swap = make_swap(direction=direction, is_provider=is_provider) + restored = SwapData(**json.loads(json.dumps(swap, cls=MyEncoder))) + restored._payment_hash = PAYMENT_HASH + self.assertEqual(contract(direction), SwapManager._validate_taproot_swap(restored)) + + restored.redeem_script = bytes(34) + with self.assertRaisesRegex(ValueError, "output script"): + SwapManager._validate_taproot_swap(restored) + + def test_legacy_swap_data_deserializes_with_defaults(self): + legacy = json.loads(json.dumps(make_swap( + direction=SwapDirection.FORWARD, is_provider=False, + ), cls=MyEncoder)) + for field in ( + "protocol", "their_pubkey", "is_provider", "cooperative_tx", + "is_cooperative", "refund_cancelled", + ): + legacy.pop(field) + restored = SwapData(**legacy) + self.assertIsNone(restored.protocol) + self.assertIsNone(restored.their_pubkey) + self.assertFalse(restored.is_provider) + self.assertFalse(restored.is_cooperative) + self.assertFalse(restored.refund_cancelled) + + async def test_branch_a_taproot_swap_skips_cooperation_and_uses_script_path(self): + swap = make_swap( + direction=SwapDirection.REVERSE, is_provider=False, is_cooperative=False, + ) + SwapManager._validate_taproot_swap(swap) + manager, _funding, _watched = manager_for_watcher(swap) + await manager._claim_swap(swap) + manager.taskgroup.spawn.assert_not_awaited() + manager.wallet.txbatcher.add_sweep_input.assert_called_once() + + provider_swap = make_swap( + direction=SwapDirection.REVERSE, is_provider=True, is_cooperative=False, + ) + provider, funding_txout, watched = manager_for_cooperation(provider_swap) + tx = unsigned_cooperative_tx(watched, funding_txout) + with self.assertRaisesRegex(ValueError, "unknown"): + provider.server_claim_taproot_swap({ + "id": PAYMENT_HASH.hex(), + "sessionId": (b"\x52" * 32).hex(), + "transaction": tx.serialize_to_network(include_sigs=False), + "preimage": PREIMAGE.hex(), + }) + + def test_taproot_swap_restarts_and_reindexes(self): + swap = make_swap(direction=SwapDirection.FORWARD, is_provider=False) + restored = SwapData(**json.loads(json.dumps(swap, cls=MyEncoder))) + lnworker = SimpleNamespace( + lnwatcher=SimpleNamespace(), + get_preimage=mock.Mock(return_value=None), + register_hold_invoice=mock.Mock(), + ) + wallet = SimpleNamespace( + config=SimpleNamespace(), + db=SimpleNamespace(get_dict=lambda _key: {PAYMENT_HASH.hex(): restored}), + ) + manager = SwapManager(wallet=wallet, lnworker=lnworker) + self.assertIs(restored, manager._swaps_by_lockup_address[restored.lockup_address]) + self.assertEqual(PAYMENT_HASH, restored.payment_hash) + self.assertTrue(restored.is_cooperative) + lnworker.register_hold_invoice.assert_called_once_with( + PAYMENT_HASH, manager.hold_invoice_callback, + ) + + def test_add_invoice_preserves_ownership_and_two_phase_hold_semantics(self): + swap = make_swap(direction=SwapDirection.FORWARD, is_provider=True) + swap.preimage = PREIMAGE + invoice = SimpleNamespace( + rhash=PAYMENT_HASH.hex(), + get_amount_sat=lambda: LIGHTNING_AMOUNT, + get_id=lambda: "invoice-id", + ) + manager = SwapManager.__new__(SwapManager) + manager.swaps_lock = threading.Lock() + manager._swaps = {PAYMENT_HASH.hex(): swap} + manager.invoices_to_pay = {} + manager.wallet = SimpleNamespace( + get_invoice=lambda _invoice_id: None, + save_invoice=mock.Mock(), + ) + with mock.patch.object(Invoice, "from_bech32", return_value=invoice): + manager.server_add_swap_invoice({ + "invoice": "hold-invoice", + "refundPublicKey": USER_PUBKEY.hex(), + }) + manager.wallet.save_invoice.assert_called_once_with(invoice) + self.assertEqual({PAYMENT_HASH.hex(): 0}, manager.invoices_to_pay) + self.assertIsNone(swap.funding_txid) + + manager.invoices_to_pay = {} + manager.wallet.save_invoice.reset_mock() + with mock.patch.object(Invoice, "from_bech32", return_value=invoice): + with self.assertRaisesRegex(ValueError, "public key"): + manager.server_add_swap_invoice({ + "invoice": "hold-invoice", + "refundPublicKey": PROVIDER_PUBKEY.hex(), + }) + manager.wallet.save_invoice.assert_not_called() + + def test_all_role_witnesses_use_claim_or_refund_leaf(self): + signature = b"\x44" * 64 + for direction in SwapDirection: + for is_provider in (False, True): + with self.subTest(direction=direction, is_provider=is_provider): + swap = make_swap(direction=direction, is_provider=is_provider) + _funding, funding_txout, watched = make_funding(swap) + watched.witness_utxo = funding_txout + txin, locktime = SwapManager.create_claim_txin(txin=watched, swap=swap) + leaf = SwapLeaf.CLAIM if swap.is_reverse else SwapLeaf.REFUND + script, control = contract(direction).script_path(leaf) + txin.witness = txin.make_witness(signature) + expected = ( + [signature, PREIMAGE, script, control] + if swap.is_reverse else [signature, script, control] + ) + self.assertEqual(expected, list(txin.witness_elements())) + self.assertEqual(None if swap.is_reverse else LOCKTIME, locktime) + self.assertEqual(1 if swap.is_reverse else 0xffffffff - 2, txin.nsequence) + self.assertIsNotNone(txin.tap_script_signing_data) + + spend = PartialTransaction.from_io( + [txin], + [PartialTxOutput(scriptpubkey=b"\x51", value=ONCHAIN_AMOUNT - 1_000)], + locktime=locktime or 0, + BIP69_sort=False, + ) + txin.witness = txin.make_witness( + spend.sign_txin(0, swap.privkey) + ) + self.assertTrue(txin.is_complete()) + + async def test_reverse_client_claim_waits_for_exact_confirmed_funding(self): + for confirmed, amount, scriptpubkey, should_claim in ( + (False, ONCHAIN_AMOUNT, None, False), + (True, ONCHAIN_AMOUNT + 1, None, False), + (True, ONCHAIN_AMOUNT, bytes(34), False), + (True, ONCHAIN_AMOUNT, None, True), + ): + with self.subTest(confirmed=confirmed, amount=amount, scriptpubkey=scriptpubkey): + swap = make_swap(direction=SwapDirection.REVERSE, is_provider=False) + manager, _funding, watched = manager_for_watcher( + swap, confirmed=confirmed, amount=amount, scriptpubkey=scriptpubkey, + ) + await manager._claim_swap(swap) + self.assertEqual( + should_claim, manager.taskgroup.spawn.await_count == 1, + ) + manager.wallet.txbatcher.add_sweep_input.assert_not_called() + if not should_claim: + manager.lnworker.get_preimage.assert_not_called() + self.assertIsNone(swap.funding_txid) + if should_claim: + self.assertEqual(swap.redeem_script, watched.witness_utxo.scriptpubkey) + + async def test_local_spend_suppresses_competing_claim(self): + swap = make_swap(direction=SwapDirection.REVERSE, is_provider=False) + manager, _funding, watched = manager_for_watcher(swap) + watched.spent_height = TX_HEIGHT_LOCAL + watched.spent_txid = "44" * 32 + await manager._claim_swap(swap) + manager.wallet.txbatcher.add_sweep_input.assert_not_called() + self.assertEqual(watched.spent_txid, swap.spending_txid) + + def test_taproot_funding_output_uses_validated_output_script(self): + swap = make_swap(direction=SwapDirection.FORWARD, is_provider=False) + manager = SwapManager.__new__(SwapManager) + output = manager.create_funding_output(swap) + self.assertEqual(swap.redeem_script, output.scriptpubkey) + self.assertEqual(swap.onchain_amount, output.value) + + +class TestCooperativeSettlement(ElectrumTestCase): + @staticmethod + def _round_one_context( + *, direction=SwapDirection.REVERSE, confirmed=True, session_byte=b"\x12", + ): + swap = make_swap(direction=direction, is_provider=True) + manager, funding_txout, watched = manager_for_cooperation( + swap, confirmed=confirmed, + ) + tx = unsigned_cooperative_tx(watched, funding_txout) + request = { + "id": PAYMENT_HASH.hex(), + "sessionId": (session_byte * 32).hex(), + "transaction": tx.serialize_to_network(include_sigs=False), + } + if direction is SwapDirection.REVERSE: + request["preimage"] = PREIMAGE.hex() + return manager, swap, tx, request + + def _start_session(self, *, direction=SwapDirection.REVERSE): + manager, swap, tx, request = self._round_one_context(direction=direction) + handler = ( + manager.server_claim_taproot_swap + if direction is SwapDirection.REVERSE + else manager.server_refund_taproot_swap + ) + response = handler(request) + _checked_tx, msg32 = manager._parse_cooperative_transaction( + swap, request["transaction"], require_confirmed=True, + ) + client = MuSig2Session.create( + contract=contract(direction), + local_seckey=USER_SECKEY, + msg32=msg32, + session_id32=bytes.fromhex(request["sessionId"]), + ) + client_partial = client.sign_partial(bytes.fromhex(response["pubNonce"])) + final = { + "id": PAYMENT_HASH.hex(), + "sessionId": request["sessionId"], + "pubNonce": client.public_nonce.hex(), + "partialSignature": client_partial.hex(), + } + return manager, swap, tx, request, response, final + + async def test_reverse_claim_and_forward_refund_end_to_end(self): + for direction in SwapDirection: + with self.subTest(direction=direction): + client_swap = make_swap(direction=direction, is_provider=False) + provider_swap = make_swap(direction=direction, is_provider=True) + client_manager, _client_out, watched = manager_for_cooperation(client_swap) + provider_manager, _provider_out, _provider_watched = manager_for_cooperation( + provider_swap, + ) + requests = [] + + async def send(method, request): + requests.append(dict(request)) + handler = ( + provider_manager.server_claim_taproot_swap + if method == "claimtaprootswap" + else provider_manager.server_refund_taproot_swap + ) + return handler(request) + + client_manager._send_cooperative_request = send + signed_tx = await client_manager._create_cooperative_spend_tx( + client_swap, watched, + ) + self.assertEqual([64], [ + len(item) for item in signed_tx.inputs()[0].witness_elements() + ]) + self.assertEqual(signed_tx.txid(), client_swap.spending_txid) + self.assertEqual(signed_tx.serialize_to_network(), client_swap.cooperative_tx) + self.assertEqual(signed_tx.serialize_to_network(), provider_swap.cooperative_tx) + self.assertNotIn("destination", requests[0]) + self.assertNotIn("index", requests[0]) + self.assertEqual( + {"id", "sessionId", "transaction", "preimage"} + if direction is SwapDirection.REVERSE + else {"id", "sessionId", "transaction"}, + set(requests[0]), + ) + if direction is SwapDirection.REVERSE: + provider_manager.lnworker.save_preimage.assert_called_once_with( + PAYMENT_HASH, PREIMAGE, mark_as_public=True, + ) + + def test_canonical_unsigned_transaction_policy(self): + swap = make_swap(direction=SwapDirection.REVERSE, is_provider=True) + manager, funding_txout, watched = manager_for_cooperation(swap) + + def check(tx, **kwargs): + return manager._parse_cooperative_transaction( + swap, + tx.serialize_to_network(include_sigs=False), + expected_destination=destination_address(), + expected_value=ONCHAIN_AMOUNT - FEE, + require_confirmed=True, + **kwargs, + ) + + valid = unsigned_cooperative_tx(watched, funding_txout) + checked, msg32 = check(valid) + self.assertEqual(32, len(msg32)) + self.assertEqual(valid.serialize_to_network(include_sigs=False), + checked.serialize_to_network(include_sigs=False)) + + arbitrary = unsigned_cooperative_tx( + watched, + funding_txout, + destination=alternate_destination_address(), + value=ONCHAIN_AMOUNT - FEE - 1, + ) + arbitrary_raw = arbitrary.serialize_to_network(include_sigs=False) + checked, _msg32 = manager._parse_cooperative_transaction( + swap, arbitrary_raw, require_confirmed=True, + ) + self.assertEqual(alternate_destination_address(), checked.outputs()[0].address) + response = manager.server_claim_taproot_swap({ + "id": PAYMENT_HASH.hex(), + "sessionId": (b"\x51" * 32).hex(), + "transaction": arbitrary_raw, + "preimage": PREIMAGE.hex(), + }) + self.assertEqual(arbitrary_raw, response["transaction"]) + manager._musig_sessions.clear() + + mutations = { + "wrong txid": lambda tx: setattr( + tx.inputs()[0], "prevout", TxOutpoint(txid=b"\xff" * 32, out_idx=0)), + "wrong output index": lambda tx: setattr( + tx.inputs()[0], "prevout", + TxOutpoint(txid=tx.inputs()[0].prevout.txid, out_idx=1)), + "sequence": lambda tx: setattr(tx.inputs()[0], "nsequence", 1), + "locktime": lambda tx: setattr(tx, "locktime", 1), + "version": lambda tx: setattr(tx, "version", 1), + "destination": lambda tx: setattr( + tx.outputs()[0], "scriptpubkey", bytes.fromhex("6a")), + "value": lambda tx: setattr(tx.outputs()[0], "value", ONCHAIN_AMOUNT - FEE - 1), + "zero fee": lambda tx: setattr(tx.outputs()[0], "value", ONCHAIN_AMOUNT), + "negative fee": lambda tx: setattr(tx.outputs()[0], "value", ONCHAIN_AMOUNT + 1), + "high fee": lambda tx: setattr( + tx.outputs()[0], "value", + ONCHAIN_AMOUNT - (COOPERATIVE_SWAP_TX_SIZE * 600_000 // 1000) - 1), + } + for name, mutate in mutations.items(): + with self.subTest(name=name): + tx = unsigned_cooperative_tx(watched, funding_txout) + mutate(tx) + with self.assertRaises(ValueError): + check(tx) + + signed = unsigned_cooperative_tx(watched, funding_txout) + signed.inputs()[0].witness = bitcoin.construct_witness([b"signed"]) + with self.assertRaisesRegex(ValueError, "unsigned"): + manager._parse_cooperative_transaction( + swap, + signed.serialize_to_network(), + require_confirmed=True, + ) + + for name, inputs, outputs in ( + ("two inputs", [valid.inputs()[0], copy_txin(valid.inputs()[0])], valid.outputs()), + ("two outputs", valid.inputs(), [valid.outputs()[0], valid.outputs()[0]]), + ): + with self.subTest(name=name): + tx = PartialTransaction.from_io( + inputs, outputs, locktime=0, version=2, BIP69_sort=False, + ) + with self.assertRaisesRegex(ValueError, "shape"): + check(tx) + + def test_expected_destination_value_and_exact_funding_output(self): + swap = make_swap(direction=SwapDirection.REVERSE, is_provider=True) + manager, funding_txout, watched = manager_for_cooperation(swap) + tx = unsigned_cooperative_tx(watched, funding_txout) + raw_tx = tx.serialize_to_network(include_sigs=False) + with self.assertRaises(ValueError): + manager._parse_cooperative_transaction( + swap, raw_tx, expected_destination=contract(SwapDirection.REVERSE).address(), + expected_value=ONCHAIN_AMOUNT - FEE, require_confirmed=True, + ) + with self.assertRaises(ValueError): + manager._parse_cooperative_transaction( + swap, raw_tx, expected_destination=destination_address(), + expected_value=ONCHAIN_AMOUNT - FEE + 1, require_confirmed=True, + ) + + for field in ("value", "script"): + with self.subTest(field=field): + swap = make_swap(direction=SwapDirection.REVERSE, is_provider=True) + manager, funding_txout, watched = manager_for_cooperation(swap) + funding_tx = manager.lnwatcher.adb.get_transaction(swap.funding_txid) + if field == "value": + funding_tx.outputs()[0].value += 1 + else: + funding_tx.outputs()[0].scriptpubkey = bytes.fromhex("6a") + tx = unsigned_cooperative_tx(watched, funding_txout) + with self.assertRaisesRegex(ValueError, "funding output"): + manager._parse_cooperative_transaction( + swap, tx.serialize_to_network(include_sigs=False), + require_confirmed=True, + ) + + async def test_unconfirmed_reverse_and_preimage_mismatch_are_rejected(self): + manager, _swap, _tx, request = self._round_one_context(confirmed=False) + with self.assertRaisesRegex(ValueError, "not confirmed"): + manager.server_claim_taproot_swap(request) + request["preimage"] = (b"\xbb" * 32).hex() + manager.lnwatcher.adb.get_tx_height = lambda _txid: SimpleNamespace(conf=1) + with self.assertRaisesRegex(ValueError, "preimage"): + manager.server_claim_taproot_swap(request) + + client_swap = make_swap(direction=SwapDirection.REVERSE, is_provider=False) + client_swap.preimage = b"\xbb" * 32 + client_manager, _funding, watched = manager_for_cooperation(client_swap) + with self.assertRaisesRegex(ValueError, "preimage"): + await client_manager._create_cooperative_spend_tx(client_swap, watched) + + def test_session_expiry_reuse_capacity_one_per_swap_and_schema(self): + manager, swap, _tx, request, _response, final = self._start_session() + with mock.patch( + "electrum.submarine_swaps.time.monotonic", + return_value=time.monotonic() + MUSIG_SESSION_TTL_SEC + 1, + ): + with self.assertRaisesRegex(ValueError, "expired"): + manager.server_claim_taproot_swap(final) + with self.assertRaisesRegex(ValueError, "already used"): + manager.server_claim_taproot_swap(final) + + manager, _swap, _tx, _request, _response, final = self._start_session() + manager.server_claim_taproot_swap(final) + with self.assertRaisesRegex(ValueError, "already used"): + manager.server_claim_taproot_swap(final) + + manager, swap, _tx, request, _response, _final = self._start_session() + second = dict(request, sessionId=(b"\x13" * 32).hex()) + with self.assertRaisesRegex(ValueError, "active MuSig session"): + manager.server_claim_taproot_swap(second) + manager._musig_sessions.clear() + manager._musig_sessions = { + str(index): (time.monotonic() + 1000, None, object()) + for index in range(MUSIG_SESSION_MAX_ENTRIES) + } + with self.assertRaisesRegex(ValueError, "capacity"): + manager.server_claim_taproot_swap(second) + manager._musig_sessions.clear() + with self.assertRaisesRegex(ValueError, "lowercase"): + manager.server_claim_taproot_swap(dict( + request, sessionId=(b"\xab" * 32).hex().upper(), + )) + for extra in ("destination", "index", "unexpected"): + with self.subTest(extra=extra): + bad_request = dict(request, sessionId=sha256(extra.encode()).hex()) + bad_request[extra] = destination_address() if extra == "destination" else 0 + with self.assertRaisesRegex(ValueError, "request fields"): + manager.server_claim_taproot_swap(bad_request) + + def test_forged_partial_mismatch_and_session_consumption(self): + manager, _swap, _tx, _request, _response, final = self._start_session() + final["partialSignature"] = bytes(32).hex() + with self.assertRaisesRegex(ValueError, "partial signature"): + manager.server_claim_taproot_swap(final) + with self.assertRaisesRegex(ValueError, "already used"): + manager.server_claim_taproot_swap(final) + + for missing_field in ("pubNonce", "partialSignature"): + with self.subTest(missing_field=missing_field): + manager, _swap, _tx, _request, _response, final = self._start_session() + final.pop(missing_field) + with self.assertRaisesRegex(ValueError, "request fields"): + manager.server_claim_taproot_swap(final) + self.assertEqual({}, manager._musig_sessions) + + manager, _swap, _tx, _request, _response, final = self._start_session() + final["pubNonce"] = (b"\x02" + b"\x00" * 32) * 2 + final["pubNonce"] = final["pubNonce"].hex() + with self.assertRaises(ValueError): + manager.server_claim_taproot_swap(final) + with self.assertRaisesRegex(ValueError, "already used"): + manager.server_claim_taproot_swap(final) + + async def test_client_rejects_forged_partial_final_signature_and_identity(self): + for tamper in ("first identity", "partial", "final witness", "final identity"): + with self.subTest(tamper=tamper): + client_swap = make_swap(direction=SwapDirection.REVERSE, is_provider=False) + provider_swap = make_swap(direction=SwapDirection.REVERSE, is_provider=True) + client_manager, _client_out, watched = manager_for_cooperation(client_swap) + provider_manager, _provider_out, _watched = manager_for_cooperation(provider_swap) + calls = 0 + + async def send(_method, request): + nonlocal calls + calls += 1 + response = provider_manager.server_claim_taproot_swap(request) + if tamper == "first identity" and calls == 1: + response["transaction"] += "00" + elif tamper == "partial" and calls == 2: + response["partialSignature"] = bytes(32).hex() + elif tamper == "final witness" and calls == 2: + tx = Transaction(response["transaction"]) + tx.inputs()[0].witness = bitcoin.construct_witness([bytes(64)]) + response["transaction"] = tx.serialize_to_network() + elif tamper == "final identity" and calls == 2: + tx = Transaction(response["transaction"]) + tx.outputs()[0].value -= 1 + response["transaction"] = tx.serialize_to_network() + return response + + client_manager._send_cooperative_request = send + with self.assertRaises(ValueError): + await client_manager._create_cooperative_spend_tx(client_swap, watched) + self.assertIsNone(client_swap.cooperative_tx) + + def test_forward_refund_payment_state_checked_in_both_rounds(self): + start_states = { + "paid": lambda manager, swap: manager.lnworker.get_payment_status.configure_mock( + return_value=PR_PAID), + "inflight status": lambda manager, swap: manager.lnworker.get_payment_status.configure_mock( + return_value=PR_INFLIGHT), + "memory inflight": lambda manager, swap: manager.lnworker.inflight_payments.add( + swap.payment_hash.hex()), + "persisted HTLC": lambda manager, swap: manager.lnworker.get_payments.configure_mock( + return_value={swap.payment_hash: [object()]}), + } + for name, set_state in start_states.items(): + with self.subTest(round="start", state=name): + manager, swap, _tx, request = self._round_one_context( + direction=SwapDirection.FORWARD, + ) + set_state(manager, swap) + with self.assertRaises(ValueError): + manager.server_refund_taproot_swap(request) + self.assertEqual({}, manager._musig_sessions) + + with self.subTest(round="finish", state=name): + manager, swap, _tx, _request, _response, final = self._start_session( + direction=SwapDirection.FORWARD, + ) + set_state(manager, swap) + with self.assertRaises(ValueError): + manager.server_refund_taproot_swap(final) + with self.assertRaisesRegex(ValueError, "already used"): + manager.server_refund_taproot_swap(final) + + async def test_forward_refund_and_payment_start_are_serialized(self): + manager, swap, _tx, request = self._round_one_context( + direction=SwapDirection.FORWARD, + ) + payment_started = asyncio.Event() + release_payment = asyncio.Event() + + async def pay_invoice(_invoice): + payment_started.set() + await release_payment.wait() + return False, [] + + manager.wallet.get_invoice = mock.Mock(return_value=object()) + manager.lnworker.pay_invoice = mock.AsyncMock(side_effect=pay_invoice) + payment_task = asyncio.create_task(manager.pay_invoice(swap.payment_hash.hex())) + await payment_started.wait() + self.assertTrue(swap._lightning_payment_pending) + with self.assertRaisesRegex(ValueError, "in flight"): + manager.server_refund_taproot_swap(request) + self.assertEqual({}, manager._musig_sessions) + release_payment.set() + await payment_task + self.assertFalse(swap._lightning_payment_pending) + + response = manager.server_refund_taproot_swap(request) + self.assertIn("pubNonce", response) + self.assertTrue(swap.refund_cancelled) + manager.lnworker.pay_invoice.reset_mock() + manager.invoices_to_pay[swap.payment_hash.hex()] = 0 + await manager.pay_invoice(swap.payment_hash.hex()) + manager.lnworker.pay_invoice.assert_not_awaited() + self.assertNotIn(swap.payment_hash.hex(), manager.invoices_to_pay) + + async def test_forward_client_waits_for_timeout(self): + swap = make_swap(direction=SwapDirection.FORWARD, is_provider=False) + manager, _funding, watched = manager_for_cooperation( + swap, height=LOCKTIME - 1, + ) + with self.assertRaisesRegex(ValueError, "timeout"): + await manager._create_cooperative_spend_tx(swap, watched) + + async def test_timeout_refusal_and_error_use_script_fallback(self): + for error in ( + SwapServerError("refused"), asyncio.TimeoutError(), ValueError("bad response"), + ): + with self.subTest(error=type(error).__name__): + swap = make_swap(direction=SwapDirection.REVERSE, is_provider=False) + manager, _funding, watched = manager_for_cooperation(swap) + manager._create_cooperative_spend_tx = mock.AsyncMock(side_effect=error) + manager._claim_swap = mock.AsyncMock() + swap._coop_spend_pending = True + await manager._cooperative_spend(swap, watched) + self.assertTrue(swap._coop_spend_failed) + self.assertFalse(swap._coop_spend_pending) + manager._claim_swap.assert_awaited_once_with(swap) + + async def test_managed_task_spawn_failure_resets_pending_flag(self): + swap = make_swap(direction=SwapDirection.REVERSE, is_provider=False) + manager, _funding, _watched = manager_for_cooperation(swap) + + async def fail_spawn(coro): + coro.close() + raise RuntimeError("task group stopped") + + manager.taskgroup.spawn = mock.AsyncMock(side_effect=fail_spawn) + with self.assertRaisesRegex(RuntimeError, "task group stopped"): + await manager._claim_swap(swap) + self.assertFalse(swap._coop_spend_pending) + + async def test_cooperative_response_wait_is_bounded_to_30_seconds(self): + class Transport: + def __init__(self): + self.is_connected = asyncio.Event() + self.is_connected.set() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + return None + + async def send_request_to_server(self, _method, _request): + await asyncio.Event().wait() + + timeouts = [] + + async def wait(awaitable, *, timeout): + timeouts.append(timeout) + if timeout == 15: + return await awaitable + awaitable.close() + raise asyncio.TimeoutError + + manager = SimpleNamespace(create_transport=lambda: Transport()) + with mock.patch("electrum.submarine_swaps.wait_for2", new=wait): + with self.assertRaisesRegex(SwapServerError, "did not reply"): + await SwapManager._send_cooperative_request(manager, "method", {}) + self.assertEqual([15, 30], timeouts) + + async def test_local_competitor_suppressed_and_persisted_tx_rebroadcasts_after_restart(self): + client_swap = make_swap(direction=SwapDirection.REVERSE, is_provider=False) + provider_swap = make_swap(direction=SwapDirection.REVERSE, is_provider=True) + client_manager, _client_out, watched = manager_for_cooperation(client_swap) + provider_manager, _provider_out, _provider_watched = manager_for_cooperation(provider_swap) + + async def send(_method, request): + return provider_manager.server_claim_taproot_swap(request) + + client_manager._send_cooperative_request = send + signed_tx = await client_manager._create_cooperative_spend_tx(client_swap, watched) + provider_manager.network.get_local_height = lambda: LOCKTIME - 1 + await provider_manager._claim_swap(provider_swap) + provider_manager.network.broadcast_transaction.assert_awaited_once() + watched.spent_height = TX_HEIGHT_LOCAL + watched.spent_txid = "55" * 32 + client_manager.network.broadcast_transaction.reset_mock() + await client_manager._claim_swap(client_swap) + client_manager.network.broadcast_transaction.assert_not_awaited() + + watched.spent_txid = signed_tx.txid() + client_manager.network.broadcast_transaction.reset_mock() + await client_manager._claim_swap(client_swap) + client_manager.network.broadcast_transaction.assert_awaited_once() + rebroadcast = client_manager.network.broadcast_transaction.await_args.args[0] + self.assertEqual(client_swap.cooperative_tx, rebroadcast.serialize_to_network()) + client_manager.taskgroup.spawn.assert_not_awaited() + client_manager.wallet.txbatcher.add_sweep_input.assert_not_called() + + restored = SwapData(**json.loads(json.dumps(client_swap, cls=MyEncoder))) + restored._payment_hash = PAYMENT_HASH + SwapManager._validate_taproot_swap(restored) + tampered = SwapData(**json.loads(json.dumps(restored, cls=MyEncoder))) + tampered._payment_hash = PAYMENT_HASH + tampered_tx = Transaction(tampered.cooperative_tx) + tampered_tx.inputs()[0].witness = bitcoin.construct_witness([bytes(64)]) + tampered.cooperative_tx = tampered_tx.serialize_to_network() + self.assertEqual(restored.spending_txid, tampered_tx.txid()) + with self.assertRaisesRegex(ValueError, "signature"): + SwapManager._validate_taproot_swap(tampered) + restored._funding_prevout = TxOutpoint( + txid=bytes.fromhex(restored.funding_txid), out_idx=1, + ) + with self.assertRaisesRegex(ValueError, "funding output"): + SwapManager._validate_taproot_swap(restored) + restored._funding_prevout = watched.prevout + restart_manager = SimpleNamespace( + network=SimpleNamespace(broadcast_transaction=mock.AsyncMock()), + logger=logger(), + ) + await SwapManager._rebroadcast_cooperative_spend(restart_manager, restored) + restarted_tx = restart_manager.network.broadcast_transaction.await_args.args[0] + self.assertEqual(restored.cooperative_tx, restarted_tx.serialize_to_network()) + self.assertEqual(restored.spending_txid, restarted_tx.txid()) diff --git a/tests/test_taproot_swaps.py b/tests/test_taproot_swaps.py new file mode 100644 index 000000000000..8ca87d35e4ac --- /dev/null +++ b/tests/test_taproot_swaps.py @@ -0,0 +1,596 @@ +# Copyright (C) 2026 The Electrum developers +# Distributed under the MIT software license, see the accompanying +# file LICENCE or http://www.opensource.org/licenses/mit-license.php + +from unittest.mock import patch + +from electrum_ecc import ECPrivkey, ECPubkey, musig + +from electrum.bitcoin import ( + LEAF_VERSION_TAPSCRIPT, + NLOCKTIME_BLOCKHEIGHT_MAX, + address_to_script, + construct_witness, + witness_push, +) +from electrum.crypto import sha256 +from electrum.taproot_swaps import ( + MuSig2Session, + SwapDirection, + SwapLeaf, + TaprootSwapContract, +) + +from . import ElectrumTestCase + + +# Generated independently with Boltz Core 5.0.0 at commit +# 336737051d62e73baf27bff5878775e11ed46482, using swapTree, +# reverseSwapTree, Musig.create([provider, user]), tweakMusig, and +# createControlBlock. The secrets are scalars 1 and 2. +PROVIDER_PUBKEY = bytes.fromhex( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" +) +USER_PUBKEY = bytes.fromhex( + "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5" +) +PREIMAGE = b"\xaa" * 32 +PAYMENT_HASH = sha256(PREIMAGE) +LOCKTIME = 800_000 + +FORWARD_ROOT = bytes.fromhex( + "75372846f2df145268cb58b82356acb491acb36f7de6af011edc06204fb0e6a6" +) +REVERSE_ROOT = bytes.fromhex( + "4fb9cb38b2fc4c13b2a7f6014281ff40a7ba80ee0c731ecb4d3ed2d3d741d7d1" +) +INTERNAL_PUBKEY = bytes.fromhex( + "3b46d262d2f610e9038b44beabdfe97ab5a0feb89870acc2264edfb7f63ec2ec" +) +FORWARD_ADDRESS = ( + "bc1pqq3wm8st9gsulhkf289sm547l63r8zl0zvsa6lul42yhh84eqeaqtw5767" +) +REVERSE_ADDRESS = ( + "bc1p5cr57xdpjm8l7jwm2yyhrwuq4y2p7u808hqeau7alf0ug2lcn9kqqks077" +) + + +def make_contract(direction=SwapDirection.FORWARD, **overrides): + params = { + "direction": direction, + "payment_hash": PAYMENT_HASH, + "locktime": LOCKTIME, + "provider_pubkey": PROVIDER_PUBKEY, + "user_pubkey": USER_PUBKEY, + } + params.update(overrides) + return TaprootSwapContract(**params) + + +class TestTaprootSwapContract(ElectrumTestCase): + def test_forward_reference_vector(self): + contract = make_contract() + expected_claim = bytes.fromhex( + "a914b3256e789b42b4e73b0954beb516ec7dfc032dd3882079be667ef9dcbb" + "ac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798ac" + ) + expected_refund = bytes.fromhex( + "20c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c7" + "09ee5ad0300350cb1" + ) + self.assertEqual(PROVIDER_PUBKEY, contract.claim_pubkey) + self.assertEqual(USER_PUBKEY, contract.refund_pubkey) + self.assertEqual(expected_claim, contract.claim_leaf_script) + self.assertEqual(expected_refund, contract.refund_leaf_script) + self.assertEqual(FORWARD_ROOT, contract.merkle_root) + self.assertEqual(INTERNAL_PUBKEY, contract.internal_pubkey) + self.assertEqual( + "51200022ed9e0b2a21cfdec951cb0dd2befea2338bef1321dd7f9faa897b9e" + "b9067a", + contract.output_script.hex(), + ) + self.assertEqual(FORWARD_ADDRESS, contract.address()) + + def test_reverse_reference_vector_and_roles(self): + contract = make_contract(SwapDirection.REVERSE) + expected_claim = bytes.fromhex( + "82012088a914b3256e789b42b4e73b0954beb516ec7dfc032dd38820c6047f" + "9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5ac" + ) + expected_refund = bytes.fromhex( + "2079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8" + "1798ad0300350cb1" + ) + self.assertEqual(USER_PUBKEY, contract.claim_pubkey) + self.assertEqual(PROVIDER_PUBKEY, contract.refund_pubkey) + self.assertEqual(expected_claim, contract.claim_leaf_script) + self.assertEqual(expected_refund, contract.refund_leaf_script) + self.assertEqual(REVERSE_ROOT, contract.merkle_root) + self.assertEqual(INTERNAL_PUBKEY, contract.internal_pubkey) + self.assertEqual( + "5120a6074f19a196cfff49db510971bb80a9141f70ef3dc19ef3ddfa5fc42b" + "f8996c", + contract.output_script.hex(), + ) + self.assertEqual(REVERSE_ADDRESS, contract.address()) + + def test_provider_first_key_aggregation_is_direction_independent(self): + forward = make_contract() + reverse = make_contract(SwapDirection.REVERSE) + swapped_roles = make_contract( + provider_pubkey=USER_PUBKEY, user_pubkey=PROVIDER_PUBKEY + ) + self.assertEqual(forward.internal_pubkey, reverse.internal_pubkey) + self.assertNotEqual( + forward.internal_pubkey, swapped_roles.internal_pubkey + ) + + def test_address_decodes_to_output_script(self): + for direction in SwapDirection: + with self.subTest(direction=direction): + contract = make_contract(direction) + self.assertEqual( + contract.output_script, + address_to_script(contract.address()), + ) + + def test_cltv_uses_canonical_script_numbers(self): + expected_suffixes = { + 1: "51b1", + 16: "60b1", + 17: "0111b1", + 127: "017fb1", + 128: "028000b1", + NLOCKTIME_BLOCKHEIGHT_MAX: "04ff64cd1db1", + } + for locktime, suffix in expected_suffixes.items(): + with self.subTest(locktime=locktime): + script = make_contract(locktime=locktime).refund_leaf_script + self.assertTrue(script.hex().endswith(suffix)) + + def test_contract_rejects_invalid_boundaries(self): + invalid_calls = [ + lambda: make_contract(direction="forward"), + lambda: make_contract(payment_hash=bytearray(32)), + lambda: make_contract(payment_hash=bytes(31)), + lambda: make_contract(locktime=True), + lambda: make_contract(locktime=0), + lambda: make_contract(locktime=NLOCKTIME_BLOCKHEIGHT_MAX + 1), + lambda: make_contract(provider_pubkey=PROVIDER_PUBKEY[1:]), + lambda: make_contract(provider_pubkey=b"\x04" + bytes(32)), + lambda: make_contract(provider_pubkey=b"\x02" + b"\xff" * 32), + lambda: make_contract(user_pubkey=PROVIDER_PUBKEY), + lambda: make_contract( + user_pubkey=b"\x03" + PROVIDER_PUBKEY[1:] + ), + ] + for call in invalid_calls: + with self.subTest(call=call): + with self.assertRaises((TypeError, ValueError)): + call() + + def test_control_block_reference_vectors(self): + vectors = { + (SwapDirection.FORWARD, SwapLeaf.CLAIM): ( + "c03b46d262d2f610e9038b44beabdfe97ab5a0feb89870acc2264edfb7f63" + "ec2eceb0b7997538e555615e4c0460ef19a4045cc07d87897f75c5a1475" + "165c9efbb4" + ), + (SwapDirection.FORWARD, SwapLeaf.REFUND): ( + "c03b46d262d2f610e9038b44beabdfe97ab5a0feb89870acc2264edfb7f63" + "ec2ec30f0249ca36c7b1baa9d607c8df543cb04160e76cd47100ca595f806" + "af2c3de3" + ), + (SwapDirection.REVERSE, SwapLeaf.CLAIM): ( + "c03b46d262d2f610e9038b44beabdfe97ab5a0feb89870acc2264edfb7f63" + "ec2ece2e90dbc4796d23159a2b8e349dda9ce48c9edcd4738cecb4a562aa0" + "99c73fe4" + ), + (SwapDirection.REVERSE, SwapLeaf.REFUND): ( + "c03b46d262d2f610e9038b44beabdfe97ab5a0feb89870acc2264edfb7f63" + "ec2ecb99ef483be975255d995eceac74f027a9146509c4b5217a44fb9a7d1" + "6024dbf7" + ), + } + for (direction, leaf), expected_control_block in vectors.items(): + with self.subTest(direction=direction, leaf=leaf): + contract = make_contract(direction) + script, control_block = contract.script_path(leaf) + expected_script = ( + contract.claim_leaf_script + if leaf is SwapLeaf.CLAIM + else contract.refund_leaf_script + ) + self.assertEqual(expected_script, script) + self.assertEqual(expected_control_block, control_block.hex()) + self.assertEqual(65, len(control_block)) + self.assertEqual( + LEAF_VERSION_TAPSCRIPT, control_block[0] & 0xFE + ) + self.assertEqual(contract.internal_pubkey, control_block[1:33]) + + def test_control_block_commits_odd_output_parity(self): + contract = make_contract(locktime=800_003) + _, control_block = contract.script_path(SwapLeaf.CLAIM) + self.assertEqual( + "5120f0bfbe2cdf78f18ce2a7ca9efad7eec2b20d7faa1950ab17d874c46c" + "d17841b4", + contract.output_script.hex(), + ) + self.assertEqual(0xC1, control_block[0]) + + def test_script_path_witness_order(self): + signature = b"\x11" * 64 + for direction in SwapDirection: + contract = make_contract(direction) + claim_script, claim_control = contract.script_path(SwapLeaf.CLAIM) + refund_script, refund_control = contract.script_path( + SwapLeaf.REFUND + ) + claim_witness = [signature, PREIMAGE, claim_script, claim_control] + refund_witness = [signature, refund_script, refund_control] + self.assertEqual( + construct_witness(claim_witness), + b"\x04" + + witness_push(signature) + + witness_push(PREIMAGE) + + witness_push(claim_script) + + witness_push(claim_control), + ) + self.assertEqual( + construct_witness(refund_witness), + b"\x03" + + witness_push(signature) + + witness_push(refund_script) + + witness_push(refund_control), + ) + self.assertEqual(signature, claim_witness[0]) + self.assertEqual(PREIMAGE, claim_witness[1]) + self.assertEqual(claim_script, claim_witness[-2]) + self.assertEqual(signature, refund_witness[0]) + self.assertEqual(refund_script, refund_witness[-2]) + + def test_script_path_rejects_untyped_leaf(self): + with self.assertRaises(TypeError): + make_contract().script_path("claim") + + +class TestContractValidation(ElectrumTestCase): + def test_both_directions_accept_canonical_tree(self): + for direction in SwapDirection: + with self.subTest(direction=direction): + contract = make_contract(direction) + tree = contract.serialized_tree() + claim_output = tree["claimLeaf"]["output"] + tree["claimLeaf"]["output"] = claim_output.upper() + contract.validate_provider_data( + serialized_tree=tree, address=contract.address() + ) + + def test_rejects_unexpected_leaves_and_fields(self): + contract = make_contract() + variants = [] + + tree = contract.serialized_tree() + tree["extraLeaf"] = tree["claimLeaf"].copy() + variants.append(tree) + + tree = contract.serialized_tree() + tree["claimLeaf"]["metadata"] = True + variants.append(tree) + + for tree in variants: + with self.subTest(tree=tree), self.assertRaisesRegex( + ValueError, "only" + ): + contract.validate_provider_data( + serialized_tree=tree, address=contract.address() + ) + + def test_rejects_tree_and_address_tampering(self): + contract = make_contract() + tree = contract.serialized_tree() + tree["claimLeaf"]["output"] = tree["refundLeaf"]["output"] + with self.assertRaisesRegex(ValueError, "claimLeaf"): + contract.validate_provider_data( + serialized_tree=tree, address=contract.address() + ) + + tree = contract.serialized_tree() + tree["claimLeaf"], tree["refundLeaf"] = ( + tree["refundLeaf"], + tree["claimLeaf"], + ) + with self.assertRaisesRegex(ValueError, "claimLeaf"): + contract.validate_provider_data( + serialized_tree=tree, address=contract.address() + ) + + with self.assertRaisesRegex(ValueError, "address"): + contract.validate_provider_data( + serialized_tree=contract.serialized_tree(), + address=REVERSE_ADDRESS, + ) + + def test_rejects_wrong_contract_semantics(self): + original = make_contract() + variants = [ + make_contract(SwapDirection.REVERSE), + make_contract(payment_hash=sha256(b"different payment")), + make_contract(locktime=LOCKTIME + 1), + make_contract( + provider_pubkey=USER_PUBKEY, user_pubkey=PROVIDER_PUBKEY + ), + ] + for variant in variants: + with self.subTest(variant=variant): + with self.assertRaises(ValueError): + variant.validate_provider_data( + serialized_tree=original.serialized_tree(), + address=original.address(), + ) + + def test_rejects_malformed_serialized_tree(self): + contract = make_contract() + valid = contract.serialized_tree() + malformed = [ + None, + {}, + {"claimLeaf": None, "refundLeaf": valid["refundLeaf"]}, + {"claimLeaf": {}, "refundLeaf": valid["refundLeaf"]}, + { + "claimLeaf": {"version": 0xC2, "output": "00"}, + "refundLeaf": valid["refundLeaf"], + }, + { + "claimLeaf": {"version": 0xC0, "output": []}, + "refundLeaf": valid["refundLeaf"], + }, + { + "claimLeaf": {"version": 0xC0, "output": "not hex"}, + "refundLeaf": valid["refundLeaf"], + }, + { + "claimLeaf": {"version": 0xC0, "output": "00" * 10_001}, + "refundLeaf": valid["refundLeaf"], + }, + ] + for tree in malformed: + with self.subTest(tree=tree): + with self.assertRaises(ValueError): + contract.validate_provider_data( + serialized_tree=tree, address=contract.address() + ) + + def test_rejects_non_string_address(self): + with self.assertRaises(TypeError): + make_contract().validate_provider_data( + serialized_tree=make_contract().serialized_tree(), address=None + ) + + +PROVIDER_SECKEY = bytes.fromhex( + "b7e151628aed2a6abf7158809cf4f3c762e7160f38b4da56a784d9045190cfef" +) +USER_SECKEY = bytes.fromhex( + "c90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b14e5c9" +) +SIGNING_PROVIDER_PUBKEY = ECPrivkey(PROVIDER_SECKEY).get_public_key_bytes( + compressed=True +) +SIGNING_USER_PUBKEY = ECPrivkey(USER_SECKEY).get_public_key_bytes( + compressed=True +) +MSG32 = sha256(b"BIP341 key-path sighash fixture") + + +class TestMuSig2Session(ElectrumTestCase): + def setUp(self): + super().setUp() + self.contract = make_contract( + SwapDirection.REVERSE, + provider_pubkey=SIGNING_PROVIDER_PUBKEY, + user_pubkey=SIGNING_USER_PUBKEY, + ) + + def make_sessions( + self, *, provider_msg=MSG32, user_msg=MSG32, user_session_id=None + ): + provider = MuSig2Session.create( + contract=self.contract, + local_seckey=PROVIDER_SECKEY, + msg32=provider_msg, + ) + user = MuSig2Session.create( + contract=self.contract, + local_seckey=USER_SECKEY, + msg32=user_msg, + session_id32=user_session_id, + ) + return provider, user + + @staticmethod + def exchange_partials(provider, user): + provider_partial = provider.sign_partial(user.public_nonce) + user_partial = user.sign_partial(provider.public_nonce) + return provider_partial, user_partial + + def test_two_party_signature_verifies_for_funding_output(self): + provider, user = self.make_sessions() + provider_partial, user_partial = self.exchange_partials(provider, user) + provider_signature = provider.aggregate(user_partial) + user_signature = user.aggregate(provider_partial) + self.assertEqual(provider_signature, user_signature) + self.assertEqual(64, len(provider_signature)) + output_key = ECPubkey(b"\x02" + self.contract.output_script[2:]) + self.assertTrue(output_key.schnorr_verify(provider_signature, MSG32)) + self.assertEqual( + b"\x01\x40" + provider_signature, + construct_witness([provider_signature]), + ) + + def test_nonce_and_partial_wire_sizes(self): + provider, user = self.make_sessions() + self.assertEqual(66, len(provider.public_nonce)) + provider_partial, user_partial = self.exchange_partials(provider, user) + self.assertEqual(32, len(provider_partial)) + self.assertEqual(32, len(user_partial)) + + def test_public_nonces_follow_provider_first_key_order(self): + provider, user = self.make_sessions() + with patch( + "electrum.taproot_swaps.musig.nonce_agg", + wraps=musig.nonce_agg, + ) as nonce_agg: + provider.sign_partial(user.public_nonce) + user.sign_partial(provider.public_nonce) + + provider_order, user_order = [ + [nonce.to_bytes() for nonce in call.args[0]] + for call in nonce_agg.call_args_list + ] + expected_order = [provider.public_nonce, user.public_nonce] + self.assertEqual(expected_order, provider_order) + self.assertEqual(expected_order, user_order) + + def test_partial_signing_is_single_use_even_after_failure(self): + provider, user = self.make_sessions() + provider.sign_partial(user.public_nonce) + self.assertIsNone(provider._local_seckey) + self.assertIsNone(provider._secnonce) + with self.assertRaisesRegex(RuntimeError, "already signed"): + provider.sign_partial(user.public_nonce) + + provider, user = self.make_sessions() + with patch( + "electrum.taproot_swaps.musig.partial_sign", + side_effect=RuntimeError("signing failed"), + ), self.assertRaisesRegex(RuntimeError, "signing failed"): + provider.sign_partial(user.public_nonce) + self.assertIsNone(provider._local_seckey) + self.assertIsNone(provider._secnonce) + with self.assertRaisesRegex(RuntimeError, "already signed"): + provider.sign_partial(user.public_nonce) + + def test_rejects_forged_partial_signature(self): + provider, user = self.make_sessions() + provider_partial, user_partial = self.exchange_partials(provider, user) + forged = bytes([provider_partial[0] ^ 1]) + provider_partial[1:] + with self.assertRaisesRegex(ValueError, "invalid"): + user.aggregate(forged) + self.assertEqual(64, len(user.aggregate(provider_partial))) + self.assertEqual(64, len(provider.aggregate(user_partial))) + + def test_rejects_partial_from_different_nonce_session(self): + provider, user = self.make_sessions() + other_provider, other_user = self.make_sessions( + user_session_id=b"\x01" * 32 + ) + provider.sign_partial(other_user.public_nonce) + _, user_partial = self.exchange_partials(other_provider, user) + with self.assertRaisesRegex(ValueError, "invalid"): + provider.aggregate(user_partial) + + def test_rejects_message_mismatch(self): + provider, user = self.make_sessions(user_msg=sha256(b"other message")) + provider_partial, user_partial = self.exchange_partials(provider, user) + with self.assertRaisesRegex(ValueError, "invalid"): + provider.aggregate(user_partial) + with self.assertRaisesRegex(ValueError, "invalid"): + user.aggregate(provider_partial) + + def test_session_id_is_bound_into_nonce_generation(self): + with patch( + "electrum_ecc.musig.secrets.token_bytes", + return_value=b"\x03" * 32, + ): + first = MuSig2Session.create( + contract=self.contract, + local_seckey=USER_SECKEY, + msg32=MSG32, + session_id32=b"\x01" * 32, + ) + second = MuSig2Session.create( + contract=self.contract, + local_seckey=USER_SECKEY, + msg32=MSG32, + session_id32=b"\x02" * 32, + ) + self.assertNotEqual(first.public_nonce, second.public_nonce) + + def test_rejects_aggregate_before_partial_and_bad_nonce(self): + provider, _ = self.make_sessions() + with self.assertRaisesRegex(RuntimeError, "sign_partial"): + provider.aggregate(bytes(32)) + for nonce in (bytes(65), bytes(66), b"not bytes"): + provider, _ = self.make_sessions() + with self.subTest(nonce=nonce): + with self.assertRaises((TypeError, ValueError)): + provider.sign_partial(nonce) + + def test_rejects_foreign_secret_and_invalid_inputs(self): + stranger = bytes.fromhex("00" * 31 + "03") + calls = [ + lambda: MuSig2Session.create( + contract=object(), local_seckey=PROVIDER_SECKEY, msg32=MSG32 + ), + lambda: MuSig2Session.create( + contract=self.contract, local_seckey=stranger, msg32=MSG32 + ), + lambda: MuSig2Session.create( + contract=self.contract, local_seckey=bytes(32), msg32=MSG32 + ), + lambda: MuSig2Session.create( + contract=self.contract, local_seckey=bytearray(32), msg32=MSG32 + ), + lambda: MuSig2Session.create( + contract=self.contract, + local_seckey=PROVIDER_SECKEY, + msg32=bytes(31), + ), + lambda: MuSig2Session.create( + contract=self.contract, + local_seckey=PROVIDER_SECKEY, + msg32=MSG32, + session_id32=bytes(31), + ), + lambda: MuSig2Session.create( + contract=self.contract, + local_seckey=PROVIDER_SECKEY, + msg32=MSG32, + session_id32=bytearray(32), + ), + ] + for call in calls: + with self.subTest(call=call): + with self.assertRaises((TypeError, ValueError)): + call() + + def test_direct_construction_is_rejected(self): + with self.assertRaisesRegex(TypeError, "created with create"): + MuSig2Session( + contract=None, + local_seckey=None, + local_pubkey=None, + counterparty_pubkey=None, + msg32=None, + keyagg_cache=None, + secnonce=None, + pubnonce=None, + ) + + def test_final_signature_is_verified(self): + provider, user = self.make_sessions() + _, user_partial = self.exchange_partials(provider, user) + with patch( + "electrum.taproot_swaps.musig.partial_sig_agg", + return_value=bytes(64), + ), self.assertRaisesRegex(ValueError, "does not verify"): + provider.aggregate(user_partial) + + def test_aggregate_succeeds_once(self): + provider, user = self.make_sessions() + _, user_partial = self.exchange_partials(provider, user) + self.assertEqual(64, len(provider.aggregate(user_partial))) + with self.assertRaisesRegex(RuntimeError, "already aggregated"): + provider.aggregate(user_partial) diff --git a/tests/test_transaction.py b/tests/test_transaction.py index a7247c1f6345..5068516e1092 100644 --- a/tests/test_transaction.py +++ b/tests/test_transaction.py @@ -2,14 +2,15 @@ import os from typing import NamedTuple, Union -from electrum_ecc import ECPrivkey +from electrum_ecc import ECPrivkey, ECPubkey +from electrum_ecc.util import bip340_tagged_hash from electrum import transaction, bitcoin from electrum.transaction import (convert_raw_tx_to_hex, tx_from_any, Transaction, PartialTransaction, TxOutpoint, PartialTxInput, PartialTxOutput, Sighash, match_script_against_template, SCRIPTPUBKEY_TEMPLATE_ANYSEGWIT, TxOutput, script_GetOp, - MalformedBitcoinScript) + MalformedBitcoinScript, TapScriptSigningData) from electrum.util import bfh from electrum.bitcoin import (deserialize_privkey, opcodes, construct_script, construct_witness) @@ -1125,6 +1126,243 @@ def test_check_sighash_types_sighash_single_anyonecanpay(self): class TestSighashBIP341(ElectrumTestCase): + # bitcoinjs-lib test/fixtures/psbt.json at ab9fad5978bc1f4fb6542d1cde903d4427c3344e, + # "Sign PSBT with 1 input [P2TR] (script-path, 3-of-3) and one output [P2TR]". + BITCOINJS_UNSIGNED_TX = ( + "0200000001053618b8ff1d338437cd381dce8944d4cb33b1260e52017631b61b5694c36eba" + "0000000000ffffffff01801a060000000000225120df9b0b19ea415d46d1f775dfc4325c601" + "087a8d563c3f8e880e80f55d89cd54800000000" + ) + BITCOINJS_PREVOUT_SCRIPT = "5120559c78fad1de39171bd230c99cecadace9cd3862f5b92d0c74c520d4641e4b5e" + BITCOINJS_SCRIPT = ( + "208f891aaf2e2d7b146178c54a87e2aeddd9d1d686692257928e67cf08174ff375ac" + "20395f8129dd63b4a5c2f12124eaa05b7a7ed30f70e51fb93305deecc542e7f9ebba" + "20a8ab37bc1609d834c3913b3538dad1c84d7f9b6a835ffc175777915a37ae3572ba539c" + ) + BITCOINJS_CONTROL_BLOCK = ( + "c166b903f8b3576ceca26d30e8787d3c35402831916500dc5500b94cca1f38c3be" + "1a529c9fb3cd7e776d61b6225b6c610e8906fb8faa6c59ac5c3e95b5f82d29d6" + "1a529c9fb3cd7e776d61b6225b6c610e8906fb8faa6c59ac5c3e95b5f82d29d6" + ) + BITCOINJS_LEAF_HASH = "aea23415d65d2882cb2d981154af0bf419f68a353a21ca2aab22262885addccb" + BITCOINJS_PUBKEY = "8f891aaf2e2d7b146178c54a87e2aeddd9d1d686692257928e67cf08174ff375" + BITCOINJS_PRIVKEY = "bb17e45f2f0f5d872cdeb6d991f16ffb73d51e91a66c5ed2ba1dfad46e7c581e" + BITCOINJS_SIGNATURE = ( + "74ce475305d31829f4ff1b4e6f008fe71c3becbbeed3c705cdd56340e210da47" + "5c0870d8bfa8d18ddc4301acfa0cd1a53e53db3554f807806247fd5148073f3c" + ) + # Generated with bitcoinjs-lib 7.0.0 Transaction.hashForWitnessV1 from the + # fixture above. This exercises every BIP341-defined hash type with ext_flag=1. + BITCOINJS_SIGHASHES = { + Sighash.DEFAULT: "99942b0689ef782c5be603d2189325228bc999e144e10c65423f29e98fd9e938", + Sighash.ALL: "65123a859a7d0994505d641aa1259fff03a4776fcdcb3ee972ef4ac03e179f4b", + Sighash.NONE: "20e6ef102c03b2abddff3bb20b872d09e48c9f018ca9dff7c0e4ff34fb96b3ff", + Sighash.SINGLE: "ffc58d201d986f765cc773c9b355070949410408b52afa754cc8c37e356169a1", + Sighash.ALL | Sighash.ANYONECANPAY: + "327ce7a9e9dc6ca286d83198046beb7d6e5eac94967f38b4631e38719c73835a", + Sighash.NONE | Sighash.ANYONECANPAY: + "5dabc9256db23439120ad81485d91da77d149ba6984656fa6237073f996a031b", + Sighash.SINGLE | Sighash.ANYONECANPAY: + "12766ae3fcb45f6bf3aa91a5cb879e84d10d2a29651008cce8341cf015109b54", + } + BITCOINJS_ANNEX_SIGHASHES = { + Sighash.DEFAULT: "db798afde39e8caa852995b7d51fe2ad7c1d8fcb800e52def4032668538b6fbc", + Sighash.NONE | Sighash.ANYONECANPAY: + "6f58ffe203fd7fbb8ca02fc3b00e6af4ddddcf3eaba17f72feaa5798f2db29c5", + } + + # Bitcoin Core qa-assets, script_assets_test.json at + # b33d85102d169b54d966ea315ad81a636680aefa, "sighash/branched_codesep/left". + CORE_CODESEP_TX = ( + "8978a00f01c8fcf6c075055bed05468c08217fcd9834a0cea986e17a5461ad52" + "f2c96314c51302000000505f90cb018ea22401000000001600146f246454d9cda265" + "b3944397c63270c3c6b160b658010000" + ) + CORE_CODESEP_PREVOUT = ( + "cab2b101000000002251203cf3576a5e98c16948a4f626ce0fb7ac42b499875f18" + "ea92ad913ae63522d523" + ) + CORE_CODESEP_SCRIPT = ( + "01897563ab20703009f4e84cd691802b7a9075eabd8d0df500d719827f22b1301a" + "2e2874038967ab20ef1c38d2738c8d14e5e75007cb88258f2c0b5f9bc8b7e9aa9" + "748fdd2f1a5b37468ac" + ) + CORE_CODESEP_CONTROL_BLOCK = ( + "c1703009f4e84cd691802b7a9075eabd8d0df500d719827f22b1301a2e28740389" + "deb4902faa2b24a6d42639bb50c0bfbb077c97c0a0cdf4ed93d76d6175be5c24" + ) + CORE_CODESEP_PUBKEY = "703009f4e84cd691802b7a9075eabd8d0df500d719827f22b1301a2e28740389" + CORE_CODESEP_SIGNATURE = ( + "347d4d5c88fdd439ada417661324ccb7dfacc55269111f2d9cf67f8bb33d2c2f" + "88b5b9f3a75c798ce58d2bc0b5ceb3604df6a0159bf44eb7e32c3f69c0ae5960" + ) + + def _bitcoinjs_tapscript_tx(self): + tx = PartialTransaction.from_tx(Transaction(self.BITCOINJS_UNSIGNED_TX)) + txin = tx.inputs()[0] + txin.witness_utxo = TxOutput( + scriptpubkey=bfh(self.BITCOINJS_PREVOUT_SCRIPT), value=420_000 + ) + txin.tap_script_signing_data = TapScriptSigningData( + script=bfh(self.BITCOINJS_SCRIPT), + control_block=bfh(self.BITCOINJS_CONTROL_BLOCK), + ) + return tx, txin + + def test_tapscript_sighash_and_signing_bitcoinjs_vector(self): + tx, _txin = self._bitcoinjs_tapscript_tx() + preimage = tx.serialize_preimage(0) + self.assertEqual( + self.BITCOINJS_LEAF_HASH, + bitcoin.tapleaf_hash(leaf_version=0xC0, script=bfh(self.BITCOINJS_SCRIPT)).hex(), + ) + msg_hash = bip340_tagged_hash(b"TapSighash", preimage) + self.assertEqual(self.BITCOINJS_SIGHASHES[Sighash.DEFAULT], msg_hash.hex()) + + pubkey = ECPubkey(b"\x02" + bfh(self.BITCOINJS_PUBKEY)) + self.assertTrue(pubkey.schnorr_verify(bfh(self.BITCOINJS_SIGNATURE), msg_hash)) + signature = tx.sign_txin(0, bfh(self.BITCOINJS_PRIVKEY)) + self.assertEqual(64, len(signature)) + self.assertTrue(pubkey.schnorr_verify(signature, msg_hash)) + + def test_tapscript_sighash_modes_bitcoinjs(self): + tx, txin = self._bitcoinjs_tapscript_tx() + for sighash, expected in self.BITCOINJS_SIGHASHES.items(): + with self.subTest(sighash=sighash): + msg_hash = bip340_tagged_hash( + b"TapSighash", tx.serialize_preimage(0, sighash=sighash) + ) + self.assertEqual(expected, msg_hash.hex()) + + txin.tap_script_signing_data = txin.tap_script_signing_data._replace(annex=b"\x50annex") + for sighash, expected in self.BITCOINJS_ANNEX_SIGHASHES.items(): + with self.subTest(sighash=sighash, annex=True): + msg_hash = bip340_tagged_hash( + b"TapSighash", tx.serialize_preimage(0, sighash=sighash) + ) + self.assertEqual(expected, msg_hash.hex()) + + def test_tapscript_codesep_position_is_opcode_ordinal_bitcoin_core(self): + tx = PartialTransaction.from_tx(Transaction(self.CORE_CODESEP_TX)) + txin = tx.inputs()[0] + txin.witness_utxo = TxOutput.from_network_bytes(bfh(self.CORE_CODESEP_PREVOUT)) + txin.tap_script_signing_data = TapScriptSigningData( + script=bfh(self.CORE_CODESEP_SCRIPT), + control_block=bfh(self.CORE_CODESEP_CONTROL_BLOCK), + codesep_pos=3, + ) + msg_hash = bip340_tagged_hash(b"TapSighash", tx.serialize_preimage(0)) + pubkey = ECPubkey(b"\x02" + bfh(self.CORE_CODESEP_PUBKEY)) + self.assertTrue(pubkey.schnorr_verify(bfh(self.CORE_CODESEP_SIGNATURE), msg_hash)) + + # OP_CODESEPARATOR is opcode ordinal 3 but byte offset 4 in this script. + txin.tap_script_signing_data = txin.tap_script_signing_data._replace(codesep_pos=4) + with self.assertRaisesRegex(ValueError, "code-separator position"): + tx.serialize_preimage(0) + + def test_tapscript_control_block_depth_boundary(self): + tx, txin = self._bitcoinjs_tapscript_tx() + data = txin.tap_script_signing_data + internal_key = data.control_block[1:33] + merkle_path = data.control_block[33:] + bytes(32 * 126) + root = bitcoin.tapleaf_hash(leaf_version=0xC0, script=data.script) + for pos in range(0, len(merkle_path), 32): + sibling = merkle_path[pos:pos + 32] + root = bip340_tagged_hash(b"TapBranch", min(root, sibling) + max(root, sibling)) + parity, output_key = bitcoin.taproot_tweak_pubkey(internal_key, root) + control_block = bytes([0xC0 | parity]) + internal_key + merkle_path + txin.witness_utxo = TxOutput(scriptpubkey=b"\x51\x20" + output_key, value=txin.value_sats()) + + data = data._replace(control_block=control_block) + self.assertEqual(128, (len(control_block) - 33) // 32) + data.validate(scriptpubkey=txin.scriptpubkey) + with self.assertRaisesRegex(ValueError, "too deep"): + data._replace(control_block=control_block + bytes(32)).validate( + scriptpubkey=txin.scriptpubkey + ) + + def test_tapscript_signing_rejects_tampered_metadata(self): + def mutate_script(data): + return data._replace(script=data.script + b"\x00") + + def mutate_control_path(data): + return data._replace(control_block=data.control_block[:-1] + bytes([data.control_block[-1] ^ 1])) + + def mutate_leaf_version(data): + return data._replace(control_block=b"\xc2" + data.control_block[1:]) + + for name, mutate in ( + ("script", mutate_script), + ("control path", mutate_control_path), + ("leaf version", mutate_leaf_version), + ("internal key", lambda data: data._replace( + control_block=data.control_block[:1] + b"\xff" * 32 + data.control_block[33:])), + ("parity", lambda data: data._replace( + control_block=bytes([data.control_block[0] ^ 1]) + data.control_block[1:])), + ("annex", lambda data: data._replace(annex=b"invalid")), + ("key version", lambda data: data._replace(key_version=1)), + ("codesep range", lambda data: data._replace(codesep_pos=2**32)), + ("codesep opcode", lambda data: data._replace(codesep_pos=0)), + ): + with self.subTest(name=name): + tx, txin = self._bitcoinjs_tapscript_tx() + txin.tap_script_signing_data = mutate(txin.tap_script_signing_data) + with self.assertRaises((TypeError, ValueError)): + tx.sign_txin(0, bfh(self.BITCOINJS_PRIVKEY)) + + tx, txin = self._bitcoinjs_tapscript_tx() + txin.witness_utxo = TxOutput( + scriptpubkey=txin.scriptpubkey[:-1] + bytes([txin.scriptpubkey[-1] ^ 1]), + value=txin.value_sats(), + ) + with self.assertRaisesRegex(ValueError, "control block"): + tx.sign_txin(0, bfh(self.BITCOINJS_PRIVKEY)) + + for control_block in (b"", b"\xc0" + bytes(33), b"\xc0" + bytes(32 * 130)): + with self.subTest(control_block_len=len(control_block)): + tx, txin = self._bitcoinjs_tapscript_tx() + txin.tap_script_signing_data = txin.tap_script_signing_data._replace( + control_block=control_block + ) + with self.assertRaisesRegex(ValueError, "control block"): + tx.sign_txin(0, bfh(self.BITCOINJS_PRIVKEY)) + + def test_tapscript_signing_rejects_invalid_transaction_metadata(self): + tx, txin = self._bitcoinjs_tapscript_tx() + txin.sighash = 4 + with self.assertRaisesRegex(ValueError, "SIGHASH_FLAG"): + tx.sign_txin(0, bfh(self.BITCOINJS_PRIVKEY)) + + tx, txin = self._bitcoinjs_tapscript_tx() + txin.prevout = TxOutpoint(txid=bytes(31), out_idx=0) + with self.assertRaisesRegex(ValueError, "outpoint"): + tx.sign_txin(0, bfh(self.BITCOINJS_PRIVKEY)) + + tx, txin = self._bitcoinjs_tapscript_tx() + txin._witness_utxo = None + with self.assertRaisesRegex(ValueError, "input value"): + tx.sign_txin(0, bfh(self.BITCOINJS_PRIVKEY)) + with self.assertRaisesRegex(ValueError, "input index"): + tx.sign_txin(-1, bfh(self.BITCOINJS_PRIVKEY)) + + tx, _txin = self._bitcoinjs_tapscript_tx() + tx.add_inputs([ + PartialTxInput(prevout=TxOutpoint(txid=b"\x02" * 32, out_idx=0)) + ], BIP69_sort=False) + with self.assertRaisesRegex(ValueError, "input value"): + tx.sign_txin(0, bfh(self.BITCOINJS_PRIVKEY)) + + def test_tapscript_metadata_propagation_and_psbt_boundary(self): + tx, txin = self._bitcoinjs_tapscript_tx() + serialized = tx.serialize_as_bytes(force_psbt=True) + copied_txin = PartialTxInput.from_txin(txin) + self.assertEqual(txin.tap_script_signing_data, copied_txin.tap_script_signing_data) + copied_tx = PartialTransaction.from_tx(tx) + self.assertEqual(txin.tap_script_signing_data, copied_tx.inputs()[0].tap_script_signing_data) + + txin.tap_script_signing_data = txin.tap_script_signing_data._replace(annex=b"\x50annex") + self.assertEqual(serialized, tx.serialize_as_bytes(force_psbt=True)) + def test_taproot_keypath_spending(self): test_vector_file = os.path.join(os.path.dirname(__file__), "bip-0341", "wallet-test-vectors.json") with open(test_vector_file, "r") as f: diff --git a/tests/test_txbatcher.py b/tests/test_txbatcher.py index 692cc2c210e5..33150dfb4661 100644 --- a/tests/test_txbatcher.py +++ b/tests/test_txbatcher.py @@ -10,7 +10,10 @@ from electrum import SimpleConfig from electrum import util from electrum.address_synchronizer import TX_HEIGHT_UNCONFIRMED -from electrum.transaction import Transaction, PartialTxInput, PartialTxOutput, TxOutpoint +from electrum.transaction import ( + Transaction, PartialTransaction, PartialTxInput, PartialTxOutput, TapScriptSigningData, TxOutpoint, +) +from electrum.txbatcher import TxBatch from electrum.logging import console_stderr_handler, Logger from electrum.submarine_swaps import SwapManager, SwapData from electrum.lnsweep import SweepInfo, sweep_ctx_anchor @@ -144,6 +147,22 @@ def _create_wallet(self): self.network.wallets.append(wallet) return wallet + def test_add_sweep_info_preserves_tapscript_signing_metadata(self): + source = PartialTxInput(prevout=TxOutpoint(txid=b"\x01" * 32, out_idx=0)) + source.make_witness = lambda _sig: b"" + source.privkey = b"\x02" * 32 + source.tap_script_signing_data = TapScriptSigningData( + script=b"\x51", control_block=b"\xc0" + b"\x03" * 32 + ) + rebuilt = PartialTxInput(prevout=source.prevout) + batch = TxBatch.__new__(TxBatch) + batch.batch_inputs = {source.prevout: mock.Mock(txin=source)} + + batch.add_sweep_info_to_tx(PartialTransaction.from_io( + [rebuilt], [PartialTxOutput(scriptpubkey=b"", value=0)], BIP69_sort=False + )) + self.assertIs(source.tap_script_signing_data, rebuilt.tap_script_signing_data) + @mock.patch.object(wallet.Abstract_Wallet, 'save_db') async def test_batch_payments(self, mock_save_db): # output 1: tx1(o1) ---------------