From eb33ee186225960626aac538b2ac85ee1e3ff240 Mon Sep 17 00:00:00 2001 From: topologoanatom Date: Mon, 4 May 2026 16:48:53 +0300 Subject: [PATCH 1/3] add timelock recovery path for btc p2-sh/wsh/tr Changed demo scripts for bitcoin regtest. Elements versions are broken now --- cli/scripts/demo_bitcoin.sh | 60 ++-- cli/scripts/demo_btc_recovery_path.sh | 152 +++++++++ cli/src/commands/address/mod.rs | 1 - cli/src/commands/address/multisig.rs | 54 +++- cli/src/commands/address/p2tr.rs | 75 ----- cli/src/commands/btc_tx/create.rs | 14 +- cli/src/commands/btc_tx/finalize.rs | 173 +++++++---- cli/src/commands/btc_tx/sign.rs | 216 +++++++++---- cli/src/commands/tx/create.rs | 5 +- cli/src/commands/tx/finalize.rs | 124 +++++--- cli/src/main.rs | 72 +++-- core/src/utils.rs | 427 ++++++++++++++++++++++++-- service/src/handlers/sign_psbt.rs | 139 +++++++-- service/src/handlers/sign_pset.rs | 12 +- service/src/validation.rs | 177 +---------- 15 files changed, 1183 insertions(+), 518 deletions(-) create mode 100755 cli/scripts/demo_btc_recovery_path.sh delete mode 100644 cli/src/commands/address/p2tr.rs diff --git a/cli/scripts/demo_bitcoin.sh b/cli/scripts/demo_bitcoin.sh index ec44587..2976574 100755 --- a/cli/scripts/demo_bitcoin.sh +++ b/cli/scripts/demo_bitcoin.sh @@ -2,6 +2,10 @@ set -e +# Make sure bitcoind is running +# bitcoind -regtest -fallbackfee=0.0002 -txindex=1 -daemon +# bitcoin-cli -regtest loadwallet + # Usage: ./scripts/demo_bitcoin.sh [p2wsh|p2sh|p2tr] SPEND_TYPE="${1:-p2wsh}" @@ -28,7 +32,8 @@ BTC="bitcoin-cli -regtest" # Simple Simplicity program that always returns true PROGRAM="zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA" -WITNESS="" +WITNESS="01" +SIMPLICITY_WITNESS="" NETWORK="regtest" echo "==== Sanity Check: Bitcoin Core running? ====" @@ -75,23 +80,14 @@ echo "User public key: $USER_PUBKEY" echo echo "==== Step 2: Create Address ====" -case "$SPEND_TYPE" in - p2tr) - ADDRESS_DATA=$(cargo run --quiet -- address p2tr \ - --pubkey $COSIGNER_PUBKEY \ - --network $NETWORK) - REDEEM_SCRIPT="" - ;; - *) - ADDRESS_DATA=$(cargo run --quiet -- address multisig \ - --pubkey1 $COSIGNER_PUBKEY \ - --pubkey2 $USER_PUBKEY \ - --network $NETWORK \ - --type $SPEND_TYPE) - REDEEM_SCRIPT=$(echo "$ADDRESS_DATA" | jq -r '.redeem_script') - echo "Redeem script: $REDEEM_SCRIPT" - ;; -esac + +ADDRESS_DATA=$(cargo run --quiet -- address multisig \ + --pubkey1 $COSIGNER_PUBKEY \ + --pubkey2 $USER_PUBKEY \ + --network $NETWORK \ + --type $SPEND_TYPE) +REDEEM_SCRIPT=$(echo "$ADDRESS_DATA" | jq -r '.redeem_script') +echo "Redeem script: $REDEEM_SCRIPT" ADDRESS=$(echo "$ADDRESS_DATA" | jq -r '.address') echo "Address ($SPEND_TYPE): $ADDRESS" @@ -124,15 +120,16 @@ case "$SPEND_TYPE" in SIGN_REQUEST=$(jq -n \ --arg psbt "$PSBT_HEX" \ --arg program "$PROGRAM" \ - --arg witness "$WITNESS" \ - '{psbt_hex: $psbt, input_index: 0, program: $program, witness: $witness, jet_env: "bitcoin"}') + --arg witness "$SIMPLICITY_WITNESS" \ + --arg user_pubkey "$USER_PUBKEY" \ + '{psbt_hex: $psbt, input_index: 0, program: $program, witness: $witness, jet_env: "bitcoin", user_pubkey: $user_pubkey}') ;; *) SIGN_REQUEST=$(jq -n \ --arg psbt "$PSBT_HEX" \ --arg redeem "$REDEEM_SCRIPT" \ --arg program "$PROGRAM" \ - --arg witness "$WITNESS" \ + --arg witness "$SIMPLICITY_WITNESS" \ '{psbt_hex: $psbt, redeem_script_hex: $redeem, input_index: 0, program: $program, witness: $witness, jet_env: "bitcoin"}') ;; esac @@ -156,22 +153,19 @@ fi echo "Cosigner signature: $(echo "$PSBT_SIGN1_DATA" | jq -r '.signature_hex')" echo +echo "==== Step 6: Second Signature (User) ====" +SIGN_ARGS="--psbt $PSBT_SIGNED1 --secret-key $USER_SECKEY --input-index 0" if [ "$SPEND_TYPE" = "p2tr" ]; then - echo "==== Step 6: Skipped (P2TR key-path requires only co-signer signature) ====" - PSBT_SIGNED2=$PSBT_SIGNED1 + SIGN_ARGS="$SIGN_ARGS --cosigner-pubkey $COSIGNER_PUBKEY" else - echo "==== Step 6: Second Signature (User) ====" - PSBT_SIGN2_DATA=$(cargo run --quiet -- btc-tx sign \ - --psbt "$PSBT_SIGNED1" \ - --secret-key "$USER_SECKEY" \ - --input-index 0 \ - --redeem-script "$REDEEM_SCRIPT") - - PSBT_SIGNED2=$(echo "$PSBT_SIGN2_DATA" | jq -r '.psbt') - echo "User signature added" - echo + SIGN_ARGS="$SIGN_ARGS --redeem-script $REDEEM_SCRIPT" fi +PSBT_SIGN2_DATA=$(cargo run --quiet -- btc-tx sign $SIGN_ARGS) +PSBT_SIGNED2=$(echo "$PSBT_SIGN2_DATA" | jq -r '.psbt') +echo "User signature added" +echo + echo "==== Step 7: Finalize PSBT ====" FINALIZE_DATA=$(cargo run --quiet -- btc-tx finalize --psbt "$PSBT_SIGNED2") FINAL_TX_HEX=$(echo "$FINALIZE_DATA" | jq -r '.transaction_hex') diff --git a/cli/scripts/demo_btc_recovery_path.sh b/cli/scripts/demo_btc_recovery_path.sh new file mode 100755 index 0000000..021dbe7 --- /dev/null +++ b/cli/scripts/demo_btc_recovery_path.sh @@ -0,0 +1,152 @@ +#!/bin/bash + +set -e + +# Usage: ./scripts/demo_bitcoin_recovery.sh [p2wsh|p2sh|p2tr] +SPEND_TYPE="${1:-p2wsh}" + +case "$SPEND_TYPE" in + p2wsh|p2sh|p2tr) ;; + *) + echo "Error: unsupported spend type '$SPEND_TYPE' for recovery path" + echo "Usage: $0 [p2wsh|p2sh|p2tr]" + exit 1 + ;; +esac + +echo "==== Spend type: $SPEND_TYPE (recovery/timelock path) ====" +echo + +echo "==== Sanity Check: bitcoin-cli available? ====" +if ! command -v bitcoin-cli &> /dev/null; then + echo "ERROR: bitcoin-cli not found in PATH" + exit 1 +fi +echo "OK: bitcoin-cli found at $(which bitcoin-cli)" + +BTC="bitcoin-cli -regtest" + +PROGRAM="zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA" +SIMPLICITY_WITNESS="" +NETWORK="regtest" +TIMELOCK="10" +BLOCK_TO_MINE="10" + +echo "==== Sanity Check: Bitcoin Core running? ====" +if ! $BTC getblockchaininfo > /dev/null 2>&1; then + echo "ERROR: Bitcoin Core is not running." + echo "Start it with: bitcoind -regtest -daemon" + exit 1 +fi +echo "OK" +echo + +echo "==== Setup: Ensure wallet has funds ====" +$BTC createwallet "demo" > /dev/null 2>&1 || true +MINER_ADDR=$($BTC getnewaddress) +BAL=$($BTC getbalance) +if (( $(echo "$BAL < 1.0" | bc -l) )); then + echo "Mining initial coins..." + $BTC generatetoaddress 101 "$MINER_ADDR" > /dev/null +fi +echo "Wallet balance: $($BTC getbalance) BTC" +echo + +echo "==== Step 0: Get Tweaked Public Key ====" +TWEAK_RESPONSE=$(curl -s -X POST http://localhost:30431/simplicity-unchained/tweak \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg program "$PROGRAM" '{program: $program, jet_env: "bitcoin"}')") + +COSIGNER_PUBKEY=$(echo "$TWEAK_RESPONSE" | jq -r '.tweaked_public_key_hex') + +if [ -z "$COSIGNER_PUBKEY" ] || [ "$COSIGNER_PUBKEY" = "null" ]; then + echo "ERROR: Failed to get tweaked public key" + echo "Response: $TWEAK_RESPONSE" + exit 1 +fi +echo "Cosigner pubkey: $COSIGNER_PUBKEY" +echo + +echo "==== Step 1: Generate User Keypair ====" +USER_KEYPAIR=$(cargo run --quiet -- keypair generate) +USER_SECKEY=$(echo "$USER_KEYPAIR" | jq -r '.secret_key') +USER_PUBKEY=$(echo "$USER_KEYPAIR" | jq -r '.public_key') +echo "User public key: $USER_PUBKEY" +echo + +echo "==== Step 2: Create Address (with $TIMELOCK block timelock) ====" +ADDRESS_DATA=$(cargo run --quiet -- address multisig \ + --pubkey1 $COSIGNER_PUBKEY \ + --pubkey2 $USER_PUBKEY \ + --network $NETWORK \ + --timelock $TIMELOCK \ + --type $SPEND_TYPE) + +REDEEM_SCRIPT=$(echo "$ADDRESS_DATA" | jq -r '.redeem_script') +ADDRESS=$(echo "$ADDRESS_DATA" | jq -r '.address') +echo "Redeem script: $REDEEM_SCRIPT" +echo "Address ($SPEND_TYPE): $ADDRESS" +echo + +echo "==== Step 3: Fund Address ====" +FUNDING_TXID=$($BTC sendtoaddress "$ADDRESS" 0.001) +echo "Funding txid: $FUNDING_TXID" +$BTC generatetoaddress 1 "$MINER_ADDR" > /dev/null +echo "Confirmed" +echo + +VOUT=$($BTC gettransaction "$FUNDING_TXID" | jq '.details[0].vout') +echo "Output index: $VOUT" +echo + +echo "==== Step 4: Create PSBT ====" +PSBT_CREATE_DATA=$(cargo run --quiet -- btc-tx create \ + -i "$FUNDING_TXID:$VOUT" \ + -o "$ADDRESS:90000" \ + --network $NETWORK \ + --sequence $TIMELOCK) + +PSBT_HEX=$(echo "$PSBT_CREATE_DATA" | jq -r '.psbt') +echo "Created PSBT: $PSBT_HEX" +echo + +echo "==== Step 5: Mine $BLOCK_TO_MINE blocks to satisfy CSV ====" +$BTC generatetoaddress $BLOCK_TO_MINE "$MINER_ADDR" > /dev/null +CURRENT_HEIGHT=$($BTC getblockcount) +echo "Mined $BLOCK_TO_MINE blocks, current height: $CURRENT_HEIGHT" +echo + +echo "==== Step 6: User Signs (recovery path, no cosigner) ====" +case "$SPEND_TYPE" in + p2tr) + PSBT_SIGN_DATA=$(cargo run --quiet -- btc-tx sign \ + --psbt "$PSBT_HEX" \ + --secret-key "$USER_SECKEY" \ + --input-index 0 \ + --cosigner-pubkey "$COSIGNER_PUBKEY" \ + --timelock "$TIMELOCK" \ + --recovery) + ;; + *) + PSBT_SIGN_DATA=$(cargo run --quiet -- btc-tx sign \ + --psbt "$PSBT_HEX" \ + --secret-key "$USER_SECKEY" \ + --input-index 0 \ + --redeem-script "$REDEEM_SCRIPT") + ;; +esac + +PSBT_SIGNED=$(echo "$PSBT_SIGN_DATA" | jq -r '.psbt') +echo "User signature: $(echo "$PSBT_SIGN_DATA" | jq -r '.signature_hex')" +echo + +echo "==== Step 7: Finalize PSBT ====" +FINALIZE_DATA=$(cargo run --quiet -- btc-tx finalize --psbt "$PSBT_SIGNED") +FINAL_TX_HEX=$(echo "$FINALIZE_DATA" | jq -r '.transaction_hex') +echo "Transaction hex: $FINAL_TX_HEX" +echo + +echo "==== Step 8: Broadcast ====" +BROADCAST_TXID=$($BTC sendrawtransaction "$FINAL_TX_HEX") +$BTC generatetoaddress 1 "$MINER_ADDR" > /dev/null +echo "Done! TXID: $BROADCAST_TXID" \ No newline at end of file diff --git a/cli/src/commands/address/mod.rs b/cli/src/commands/address/mod.rs index 8376c8d..754f0c0 100644 --- a/cli/src/commands/address/mod.rs +++ b/cli/src/commands/address/mod.rs @@ -1,5 +1,4 @@ pub mod multisig; -pub mod p2tr; #[derive(Debug)] enum ElementsNetwork { diff --git a/cli/src/commands/address/multisig.rs b/cli/src/commands/address/multisig.rs index c83e6b4..c10df4a 100644 --- a/cli/src/commands/address/multisig.rs +++ b/cli/src/commands/address/multisig.rs @@ -1,8 +1,10 @@ +use std::str::FromStr; + use anyhow::{Context, Result}; use serde_json::json; -use hal_simplicity::simplicity::bitcoin; use hal_simplicity::simplicity::elements::{AddressParams, bitcoin::PublicKey}; +use hal_simplicity::simplicity::{bitcoin, elements}; use simplicity_unchained_core::utils::{ TransactionType, generate_2of2_multisig_address_bitcoin, @@ -11,7 +13,13 @@ use simplicity_unchained_core::utils::{ use crate::commands::address::{BitcoinNetwork, ElementsNetwork}; -pub fn execute(pubkey1: &str, pubkey2: &str, network: &str, script_type: &str) -> Result<()> { +pub fn execute( + pubkey1: &str, + pubkey2: &str, + timelock_str: Option<&str>, + network: &str, + script_type: &str, +) -> Result<()> { let pk1_bytes = hex::decode(pubkey1).context("Failed to decode pubkey1")?; let pk2_bytes = hex::decode(pubkey2).context("Failed to decode pubkey2")?; @@ -23,22 +31,51 @@ pub fn execute(pubkey1: &str, pubkey2: &str, network: &str, script_type: &str) - let address_ty = match script_type { "p2sh" => TransactionType::P2SH, "p2wsh" => TransactionType::P2WSH, + "p2tr" => TransactionType::P2TR, _ => { return Err(anyhow::anyhow!( - "Unsupported script type: {}. Supported types are: p2sh, p2wsh", + "Unsupported script type: {}. Supported types are: p2sh, p2wsh, p2tr", script_type )); } }; if let Ok(network) = ElementsNetwork::try_from(network) { - let output = execute_over_elements(&pubkeys, network, address_ty)?; + let timelock = if let Some(s) = timelock_str { + if let Ok(time) = elements::Sequence::from_str(s) + && time.is_height_locked() + { + Some(time) + } else { + return Err(anyhow::anyhow!( + "Failed to parse timelock value; only block time values are supported." + )); + } + } else { + None + }; + + let output = execute_over_elements(&pubkeys, timelock, network, address_ty)?; println!("{}", serde_json::to_string_pretty(&output)?); return Ok(()); } if let Ok(network) = BitcoinNetwork::try_from(network) { - let output = execute_over_bitcoin(&pubkeys, network, address_ty)?; + let timelock = if let Some(s) = timelock_str { + if let Ok(time) = bitcoin::Sequence::from_str(s) + && time.is_height_locked() + { + Some(time) + } else { + return Err(anyhow::anyhow!( + "Failed to parse timelock value; only block time values are supported." + )); + } + } else { + None + }; + + let output = execute_over_bitcoin(&pubkeys, timelock, network, address_ty)?; println!("{}", serde_json::to_string_pretty(&output)?); return Ok(()); } @@ -51,6 +88,7 @@ pub fn execute(pubkey1: &str, pubkey2: &str, network: &str, script_type: &str) - fn execute_over_elements( pubkeys: &[PublicKey], + timelock: Option, network: ElementsNetwork, address_ty: TransactionType, ) -> Result { @@ -61,7 +99,7 @@ fn execute_over_elements( }; let (address, redeem_script) = - generate_2of2_multisig_address_elements(pubkeys, params, address_ty)?; + generate_2of2_multisig_address_elements(pubkeys, timelock, params, address_ty)?; Ok(json!({ "address": address.to_string(), "redeem_script": hex::encode(redeem_script.to_bytes()), @@ -71,6 +109,7 @@ fn execute_over_elements( fn execute_over_bitcoin( pubkeys: &[PublicKey], + timelock: Option, network: BitcoinNetwork, address_ty: TransactionType, ) -> Result { @@ -82,7 +121,8 @@ fn execute_over_bitcoin( BitcoinNetwork::Regtest => bitcoin::Network::Regtest, }; - let (address, redeem_script) = generate_2of2_multisig_address_bitcoin(pubkeys, network)?; + let (address, redeem_script) = + generate_2of2_multisig_address_bitcoin(pubkeys, timelock, network, address_ty)?; Ok(json!({ "address": address.to_string(), diff --git a/cli/src/commands/address/p2tr.rs b/cli/src/commands/address/p2tr.rs deleted file mode 100644 index d5aaa5f..0000000 --- a/cli/src/commands/address/p2tr.rs +++ /dev/null @@ -1,75 +0,0 @@ -use anyhow::{Context, Result}; -use hal_simplicity::bitcoin; -use hal_simplicity::simplicity::ToXOnlyPubkey; -use hal_simplicity::simplicity::elements::{self}; -use serde_json::json; - -use hal_simplicity::simplicity::elements::AddressParams; - -use crate::commands::address::{BitcoinNetwork, ElementsNetwork}; - -pub fn execute(pubkey: &str, network: &str) -> Result<()> { - let pk_bytes = hex::decode(pubkey).context("Failed to decode pubkey")?; - - if let Ok(network) = ElementsNetwork::try_from(network) { - let output = json!({ - "address": execute_over_elements(&pk_bytes, network)?, - "script_type": "p2tr", - "pubkey": pubkey, - }); - println!("{}", serde_json::to_string_pretty(&output)?); - return Ok(()); - } - - if let Ok(network) = BitcoinNetwork::try_from(network) { - let output = json!({ - "address": execute_over_bitcoin(&pk_bytes, network)?, - "script_type": "p2tr", - "pubkey": pubkey, - }); - - println!("{}", serde_json::to_string_pretty(&output)?); - return Ok(()); - } - - Ok(()) -} - -fn execute_over_elements(pk_bytes: &[u8], network: ElementsNetwork) -> Result { - let params = match network { - ElementsNetwork::Elements => &AddressParams::ELEMENTS, - ElementsNetwork::Liquid => &AddressParams::LIQUID, - ElementsNetwork::LiquidTestnet => &AddressParams::LIQUID_TESTNET, - }; - - let pk = elements::secp256k1_zkp::PublicKey::from_slice(pk_bytes).context("Invalid pubkey")?; - let (xonly, _) = pk.x_only_public_key(); - - let script = - elements::Script::new_v1_p2tr_tweaked(elements::schnorr::TweakedPublicKey::new(xonly)); - let address = elements::Address::from_script(&script, None, params) - .ok_or_else(|| anyhow::anyhow!("Failed to derive address from script"))?; - - Ok(address.to_string()) -} - -fn execute_over_bitcoin(pk_bytes: &[u8], network: BitcoinNetwork) -> Result { - let params = match network { - BitcoinNetwork::Bitcoin => bitcoin::params::Params::BITCOIN, - BitcoinNetwork::Testnet => bitcoin::params::Params::TESTNET3, - BitcoinNetwork::Testnet4 => bitcoin::params::Params::TESTNET4, - BitcoinNetwork::Signet => bitcoin::params::Params::SIGNET, - BitcoinNetwork::Regtest => bitcoin::params::Params::REGTEST, - }; - - let pk = bitcoin::PublicKey::from_slice(pk_bytes).context("Invalid pubkey")?; - let xonly = pk.to_x_only_pubkey(); - - let script = bitcoin::ScriptBuf::new_p2tr_tweaked( - bitcoin::key::TweakedPublicKey::dangerous_assume_tweaked(xonly), - ); - let address = bitcoin::Address::from_script(&script, ¶ms) - .map_err(|e| anyhow::anyhow!("Failed to derive address from script: {e}"))?; - - Ok(address.to_string()) -} diff --git a/cli/src/commands/btc_tx/create.rs b/cli/src/commands/btc_tx/create.rs index 6302167..77623ba 100644 --- a/cli/src/commands/btc_tx/create.rs +++ b/cli/src/commands/btc_tx/create.rs @@ -95,7 +95,8 @@ fn fetch_tx_output_rpc(txid: &str, vout: u32) -> Result { .context("failed to call bitcoin-cli")?; if !output.status.success() { - return Err(anyhow!("bitcoin-cli failed")); + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!("bitcoin-cli failed: stderr={}", stderr,)); } let json: serde_json::Value = serde_json::from_slice(&output.stdout).context("invalid JSON")?; @@ -133,7 +134,12 @@ fn get_network_kind(network: &str) -> Result { } } -pub fn execute(inputs: &[String], outputs: &[String], network: &str) -> Result<()> { +pub fn execute( + inputs: &[String], + outputs: &[String], + network: &str, + sequence: Option, +) -> Result<()> { let network_type = get_network_kind(network)?; // Parse inputs (txid:vout) @@ -156,7 +162,9 @@ pub fn execute(inputs: &[String], outputs: &[String], network: &str) -> Result<( tx_inputs.push(TxIn { previous_output: OutPoint::new(txid, vout), script_sig: ScriptBuf::new(), - sequence: bitcoin::Sequence::MAX, + sequence: sequence + .map(|s| bitcoin::Sequence::from_height(s)) + .unwrap_or(bitcoin::Sequence::MAX), witness: Default::default(), }); } diff --git a/cli/src/commands/btc_tx/finalize.rs b/cli/src/commands/btc_tx/finalize.rs index 1833b6a..3fe5110 100644 --- a/cli/src/commands/btc_tx/finalize.rs +++ b/cli/src/commands/btc_tx/finalize.rs @@ -1,12 +1,11 @@ use anyhow::{Context, Result, anyhow}; +use bitcoin::blockdata::script::PushBytes; use hal_simplicity::bitcoin::{ - Psbt, PublicKey, Script, Transaction, Witness, - consensus::serialize, - psbt, - script::{Builder, PushBytesBuf}, + self, Psbt, PublicKey, Script, TapLeafHash, Transaction, Witness, consensus::serialize, psbt, + taproot::LeafVersion, }; use serde_json::json; -use simplicity_unchained_core::utils::TransactionType; +use simplicity_unchained_core::utils::{TimelockMultisigScript, TransactionType}; pub fn execute(psbt_hex: &str) -> Result<()> { let psbt_bytes = hex::decode(psbt_hex).context("Failed to decode PSBT hex")?; @@ -52,7 +51,8 @@ pub fn execute(psbt_hex: &str) -> Result<()> { .map(|(idx, input)| { json!({ "input_index": idx, - "witness_elements": input.witness.witness_script().expect("witness scripts must be non-empty").len() + "witness_elements": input.witness.len(), + "script_sig_bytes": input.script_sig.len(), }) }) .collect(); @@ -78,7 +78,8 @@ fn extract_ordered_sigs( partial_sigs: &std::collections::BTreeMap, input_index: usize, ) -> Result>> { - let pubkeys = extract_pubkeys_from_multisig(script, input_index)?; + let pubkeys = + TimelockMultisigScript::extract_pubkeys_from_timelocked_2of2(script, input_index)?; if partial_sigs.len() < pubkeys.len() { return Err(anyhow!( @@ -100,81 +101,137 @@ fn extract_ordered_sigs( .collect() } -/// Extract public keys from the witness script (2-of-2 multisig format: OP_2 OP_2 OP_CHECKMULTISIG) -fn extract_pubkeys_from_multisig(script: &Script, input_index: usize) -> Result> { - let script_bytes = script.as_bytes(); - let mut pubkeys = Vec::new(); - let mut i = 0; - - while i < script_bytes.len() { - if script_bytes[i] == 33 && i + 33 < script_bytes.len() { - let pk_bytes = &script_bytes[i + 1..i + 34]; - if let Ok(pk) = PublicKey::from_slice(pk_bytes) { - pubkeys.push(pk); - } - i += 34; - } else { - i += 1; - } - } - - if pubkeys.is_empty() { - return Err(anyhow!( - "Input {} script contains no recognizable public keys", - input_index - )); - } - - Ok(pubkeys) -} - +// P2WSH normal spend witness: OP_0 OP_1 +// ^^^^ selects OP_IF branch fn finalize_p2wsh(tx: &mut Transaction, i: usize, input: &psbt::Input) -> Result<()> { let witness_script = input .witness_script .as_ref() .ok_or_else(|| anyhow!("Input {} missing witness_script", i))?; - let sigs = extract_ordered_sigs(witness_script, &input.partial_sigs, i)?; - - let mut witness = vec![vec![]]; // OP_0 - witness.extend(sigs); - witness.push(witness_script.to_bytes()); + let witness = if input.partial_sigs.len() >= 2 { + // Normal cosign path: OP_IF branch + let sigs = extract_ordered_sigs(witness_script, &input.partial_sigs, i)?; + let mut w = vec![vec![]]; // OP_0 dummy for CHECKMULTISIG + w.extend(sigs); + w.push(vec![0x01]); // select OP_IF + w.push(witness_script.to_bytes()); + w + } else { + // Recovery path: OP_ELSE branch + let (_, sig) = input + .partial_sigs + .iter() + .next() + .ok_or_else(|| anyhow!("Input {} has no signatures", i))?; + vec![ + sig.to_vec(), + vec![], // select OP_ELSE + witness_script.to_bytes(), + ] + }; tx.input[i].witness = Witness::from_slice(&witness); - Ok(()) } +// P2SH normal spend scriptSig: OP_0 OP_1 fn finalize_p2sh(tx: &mut Transaction, i: usize, input: &psbt::Input) -> Result<()> { let redeem_script = input .redeem_script .as_ref() .ok_or_else(|| anyhow!("Input {} missing redeem_script", i))?; - let sigs = extract_ordered_sigs(redeem_script, &input.partial_sigs, i)?; - - let mut builder = Builder::new(); - builder = builder.push_opcode(hal_simplicity::bitcoin::opcodes::OP_0); - for sig in &sigs { - let push_bytes = PushBytesBuf::try_from(sig.clone()) - .map_err(|_| anyhow!("Input {} signature too large for push", i))?; - builder = builder.push_slice(&push_bytes); - } - let redeem_push = PushBytesBuf::try_from(redeem_script.to_bytes()) - .map_err(|_| anyhow!("Input {} redeem script too large for push", i))?; - builder = builder.push_slice(&redeem_push); + let builder = if input.partial_sigs.len() >= 2 { + // Normal cosign path: OP_IF branch + let sigs = extract_ordered_sigs(redeem_script, &input.partial_sigs, i)?; + + let mut builder = bitcoin::script::Builder::new(); + builder = builder.push_slice(&[]); // OP_0 dummy + for sig in &sigs { + builder = builder.push_slice( + <&PushBytes as std::convert::TryFrom<&[u8]>>::try_from(sig.as_ref()) + .expect("signature shouldn't exceed 2^32"), + ); + } + builder.push_int(1).push_slice( + <&PushBytes>::try_from(redeem_script.as_bytes()).expect("script shouldn't exceed 2^32"), + ) + } else { + // Recovery path: OP_ELSE branch + let (_, sig) = input + .partial_sigs + .iter() + .next() + .ok_or_else(|| anyhow!("Input {} has no signatures", i))?; + + bitcoin::script::Builder::new() + .push_slice( + <&PushBytes as std::convert::TryFrom<&[u8]>>::try_from(sig.to_vec().as_ref()) + .expect("signature shouldn't exceed 2^32"), + ) + .push_slice(&[]) + .push_slice( + <&PushBytes>::try_from(redeem_script.as_bytes()) + .expect("script shouldn't exceed 2^32"), + ) + }; tx.input[i].script_sig = builder.into_script(); - Ok(()) } fn finalize_p2tr(tx: &mut Transaction, i: usize, input: &psbt::Input) -> Result<()> { - let tap_sig = input - .tap_key_sig - .ok_or_else(|| anyhow!("Input {} missing tap_key_sig", i))?; + let (control_block, (leaf_script, _)) = input + .tap_scripts + .iter() + .next() + .map(|(cb, s)| (cb.clone(), s.clone())) + .ok_or_else(|| anyhow!("Input {} missing tap_scripts", i))?; + + let leaf_hash = TapLeafHash::from_script(&leaf_script, LeafVersion::TapScript); + + let mut witness = Witness::new(); + + match TimelockMultisigScript::extract_pubkeys_from_p2tr_multisig_leaf(&leaf_script, i) { + Ok(pubkeys) => { + let mut sigs: Vec> = vec![None; pubkeys.len()]; + for ((xonly_pk, lh), tap_sig) in &input.tap_script_sigs { + if *lh != leaf_hash { + continue; + } + for (idx, pk) in pubkeys.iter().enumerate() { + if xonly_pk == pk { + sigs[idx] = Some(*tap_sig); + break; + } + } + } + + let cosigner_sig = + sigs[0].ok_or_else(|| anyhow!("Input {} missing cosigner tap_script_sig", i))?; + let user_sig = + sigs[1].ok_or_else(|| anyhow!("Input {} missing user tap_script_sig", i))?; + + witness.push(user_sig.serialize()); + witness.push(cosigner_sig.serialize()); + } + Err(_) => { + // Recovery spend — recovery leaf, single user sig + let (_, tap_sig) = input + .tap_script_sigs + .iter() + .find(|((_, lh), _)| lh == &leaf_hash) + .ok_or_else(|| anyhow!("Input {} missing recovery tap_script_sig", i))?; + + witness.push(tap_sig.serialize()); + } + } + + witness.push(leaf_script.as_bytes()); + witness.push(control_block.serialize()); - tx.input[i].witness = Witness::p2tr_key_spend(&tap_sig); + tx.input[i].witness = witness; Ok(()) } diff --git a/cli/src/commands/btc_tx/sign.rs b/cli/src/commands/btc_tx/sign.rs index dff51f7..1cdf0c9 100644 --- a/cli/src/commands/btc_tx/sign.rs +++ b/cli/src/commands/btc_tx/sign.rs @@ -1,17 +1,23 @@ use anyhow::{Context, Result}; -use hal_simplicity::bitcoin::ScriptBuf; +use hal_simplicity::bitcoin::taproot::{LeafVersion, TaprootBuilder}; +use hal_simplicity::bitcoin::{self, ScriptBuf}; use hal_simplicity::simplicity::bitcoin::{ EcdsaSighashType, PublicKey, hashes::Hash, psbt::Psbt, sighash::SighashCache, }; use hal_simplicity::simplicity::elements::secp256k1_zkp::{Message, Secp256k1, SecretKey}; use serde_json::json; -use simplicity_unchained_core::utils::TransactionType; +use simplicity_unchained_core::utils::{ + CSV_LOCK_DEFAULT_BLOCKS, TimelockMultisigScript, TransactionType, UNSPENDABLE_KEY_P2TR, +}; pub fn execute( psbt_hex: &str, secret_key_hex: &str, input_index: usize, - redeem_script_hex: &str, + redeem_script_hex: Option<&str>, + cosigner_pubkey_hex: Option<&str>, + timelock: Option, + recovery: bool, ) -> Result<()> { let psbt_bytes = hex::decode(psbt_hex).context("Failed to decode PSBT hex")?; let mut psbt: Psbt = Psbt::deserialize(&psbt_bytes).context("Failed to deserialize PSBT")?; @@ -28,10 +34,6 @@ pub fn execute( hex::decode(secret_key_hex).context("Failed to decode secret key hex")?; let secret_key = SecretKey::from_slice(&secret_key_bytes).context("Invalid secret key")?; - let redeem_script_bytes = - hex::decode(redeem_script_hex).context("Failed to decode redeem script hex")?; - let redeem_script = ScriptBuf::from(redeem_script_bytes); - let secp = Secp256k1::new(); let public_key = PublicKey::from_private_key( @@ -45,69 +47,169 @@ pub fn execute( let tx = psbt.clone().extract_tx()?; - let psbt_input = &psbt.inputs[input_index]; - - let script_pubkey = &psbt_input + let script_pubkey = psbt.inputs[input_index] .witness_utxo .as_ref() .ok_or_else(|| anyhow::anyhow!("Missing witness UTXO for input {}", input_index))? .script_pubkey .clone(); - let tx_ty = TransactionType::from(script_pubkey.as_script()); - let prev_value = psbt_input.witness_utxo.as_ref().unwrap().value; + let tx_ty = TransactionType::from(script_pubkey.as_script()); let mut sighash_cache = SighashCache::new(&tx); - let sighash = match tx_ty { - TransactionType::P2SH => *sighash_cache - .legacy_signature_hash(input_index, &redeem_script, EcdsaSighashType::All.to_u32())? - .as_byte_array(), - TransactionType::P2WSH => *sighash_cache - .p2wsh_signature_hash( - input_index, - &redeem_script, - prev_value, - EcdsaSighashType::All, - )? - .as_byte_array(), - _ => { - return Err(anyhow::anyhow!( - "Unsupported script type for tx sign: {}", - hex::encode(script_pubkey.as_bytes()) - )); + match tx_ty { + TransactionType::P2SH | TransactionType::P2WSH => { + let redeem_script_bytes = hex::decode( + redeem_script_hex + .ok_or_else(|| anyhow::anyhow!("--redeem-script required for P2SH/P2WSH"))?, + ) + .context("Failed to decode redeem script hex")?; + let redeem_script = ScriptBuf::from(redeem_script_bytes); + + let prev_value = psbt.inputs[input_index] + .witness_utxo + .as_ref() + .unwrap() + .value; + + let sighash = match tx_ty { + TransactionType::P2SH => *sighash_cache + .legacy_signature_hash( + input_index, + &redeem_script, + EcdsaSighashType::All.to_u32(), + )? + .as_byte_array(), + TransactionType::P2WSH => *sighash_cache + .p2wsh_signature_hash( + input_index, + &redeem_script, + prev_value, + EcdsaSighashType::All, + )? + .as_byte_array(), + _ => unreachable!(), + }; + + let msg = Message::from_digest(sighash); + let signature = hal_simplicity::bitcoin::ecdsa::Signature::sighash_all( + secp.sign_ecdsa(&msg, &secret_key), + ); + let sig_bytes = signature.to_vec(); + + let input = &mut psbt.inputs[input_index]; + input.partial_sigs.insert(public_key, signature); + + if script_pubkey.is_p2sh() { + if input.redeem_script.is_none() { + input.redeem_script = Some(redeem_script.clone()); + } + } else if script_pubkey.is_p2wsh() && input.witness_script.is_none() { + input.witness_script = Some(redeem_script.clone()); + } + + let partial_sigs_count = psbt.inputs[input_index].partial_sigs.len(); + let output = json!({ + "psbt": hex::encode(psbt.serialize()), + "signature_hex": hex::encode(sig_bytes), + "public_key_hex": hex::encode(public_key.to_bytes()), + "input_index": input_index, + "partial_sigs_count": partial_sigs_count, + }); + println!("{}", serde_json::to_string_pretty(&output)?); } - }; - - let msg = Message::from_digest(sighash); - let signature = - hal_simplicity::bitcoin::ecdsa::Signature::sighash_all(secp.sign_ecdsa(&msg, &secret_key)); - let sig_bytes = signature.to_vec(); - - // Add signature to PSBT - let input = &mut psbt.inputs[input_index]; - input.partial_sigs.insert(public_key, signature); - - if script_pubkey.is_p2sh() { - if input.redeem_script.is_none() { - input.redeem_script = Some(redeem_script.clone()); + TransactionType::P2TR => { + let cosigner_pubkey_bytes = hex::decode( + cosigner_pubkey_hex + .ok_or_else(|| anyhow::anyhow!("--cosigner-pubkey required for P2TR"))?, + ) + .context("Failed to decode cosigner pubkey hex")?; + let cosigner_pubkey = + PublicKey::from_slice(&cosigner_pubkey_bytes).context("Invalid cosigner pubkey")?; + + let resolved_timelock = timelock + .map(|t| bitcoin::Sequence::from_height(t)) + .unwrap_or(bitcoin::Sequence::from_height(CSV_LOCK_DEFAULT_BLOCKS)) + .0 as i64; + + let multisig_leaf = + TimelockMultisigScript::p2tr_multisig_leaf_btc(&cosigner_pubkey, &public_key); + let recovery_leaf = + TimelockMultisigScript::p2tr_recovery_leaf_btc(&public_key, resolved_timelock); + + let spend_info = TaprootBuilder::new() + .add_leaf(1, multisig_leaf.clone()) + .context("Failed to add multisig leaf")? + .add_leaf(1, recovery_leaf.clone()) + .context("Failed to add recovery leaf")? + .finalize(&secp, UNSPENDABLE_KEY_P2TR.clone()) + .map_err(|_| anyhow::anyhow!("Failed to finalize taproot"))?; + + let (active_leaf, control_block) = if recovery { + let cb = spend_info + .control_block(&(recovery_leaf.clone(), LeafVersion::TapScript)) + .ok_or_else(|| { + anyhow::anyhow!("Failed to get control block for recovery leaf") + })?; + (recovery_leaf, cb) + } else { + let cb = spend_info + .control_block(&(multisig_leaf.clone(), LeafVersion::TapScript)) + .ok_or_else(|| anyhow::anyhow!("Failed to get control block"))?; + (multisig_leaf, cb) + }; + + let prevouts: Vec<_> = psbt + .inputs + .iter() + .map(|i| { + i.witness_utxo + .clone() + .ok_or_else(|| anyhow::anyhow!("Missing witness_utxo")) + }) + .collect::>()?; + + let sighash = sighash_cache + .taproot_script_spend_signature_hash( + input_index, + &bitcoin::sighash::Prevouts::All(&prevouts), + bitcoin::sighash::ScriptPath::with_defaults(&active_leaf), + bitcoin::TapSighashType::Default, + ) + .context("Failed to compute recovery sighash")?; + + let msg = Message::from_digest(sighash.to_byte_array()); + let keypair = bitcoin::key::Keypair::from_secret_key(&secp, &secret_key); + let signature = secp.sign_schnorr(&msg, &keypair); + + let tap_sig = bitcoin::taproot::Signature { + signature, + sighash_type: bitcoin::TapSighashType::Default, + }; + + let leaf_hash = bitcoin::sighash::ScriptPath::with_defaults(&active_leaf).leaf_hash(); + let user_xonly = public_key.inner.x_only_public_key().0; + + let input = &mut psbt.inputs[input_index]; + input + .tap_script_sigs + .insert((user_xonly, leaf_hash), tap_sig); + input + .tap_scripts + .insert(control_block, (active_leaf, LeafVersion::TapScript)); + + let output = json!({ + "psbt": hex::encode(psbt.serialize()), + "signature_hex": hex::encode(signature.as_ref()), + "public_key_hex": hex::encode(public_key.to_bytes()), + "input_index": input_index, + "partial_sigs_count": 0, + }); + println!("{}", serde_json::to_string_pretty(&output)?); } - } else if script_pubkey.is_p2wsh() && input.witness_script.is_none() { - input.witness_script = Some(redeem_script.clone()); } - let partial_sigs_count = psbt.inputs[input_index].partial_sigs.len(); - - let output = json!({ - "psbt": hex::encode(psbt.serialize()), - "signature_hex": hex::encode(sig_bytes), - "public_key_hex": hex::encode(public_key.to_bytes()), - "input_index": input_index, - "partial_sigs_count": partial_sigs_count, - }); - - println!("{}", serde_json::to_string_pretty(&output)?); - Ok(()) } diff --git a/cli/src/commands/tx/create.rs b/cli/src/commands/tx/create.rs index ca4567a..ba4f2dc 100644 --- a/cli/src/commands/tx/create.rs +++ b/cli/src/commands/tx/create.rs @@ -118,6 +118,7 @@ pub fn execute( outputs: &[String], asset_id: Option<&str>, network: &str, + sequence: Option, ) -> Result<()> { let params = get_network_params(network)?; @@ -148,7 +149,9 @@ pub fn execute( previous_output: OutPoint::new(txid, vout), is_pegin: false, script_sig: elements::script::Script::new(), - sequence: elements::Sequence::MAX, + sequence: sequence + .map(|s| elements::Sequence::from_height(s)) + .unwrap_or(elements::Sequence::MAX), asset_issuance: elements::AssetIssuance::default(), witness: elements::TxInWitness::default(), }); diff --git a/cli/src/commands/tx/finalize.rs b/cli/src/commands/tx/finalize.rs index b06d275..980cd22 100644 --- a/cli/src/commands/tx/finalize.rs +++ b/cli/src/commands/tx/finalize.rs @@ -4,7 +4,7 @@ use hal_simplicity::simplicity::elements::{ bitcoin::PublicKey, encode::{deserialize, serialize}, pset::{self, PartiallySignedTransaction}, - script::{Builder, Script}, + script::{Instruction, Script}, }; use serde_json::json; use simplicity_unchained_core::utils::TransactionType; @@ -79,7 +79,7 @@ fn extract_ordered_sigs( partial_sigs: &std::collections::BTreeMap>, input_index: usize, ) -> Result>> { - let pubkeys = extract_pubkeys_from_multisig(script, input_index)?; + let pubkeys = extract_pubkeys_from_timelocked_2of2(script, input_index)?; if partial_sigs.len() < pubkeys.len() { return Err(anyhow!( @@ -101,68 +101,122 @@ fn extract_ordered_sigs( .collect() } -/// Extract public keys from the witness script (2-of-2 multisig format: OP_2 OP_2 OP_CHECKMULTISIG) -fn extract_pubkeys_from_multisig(script: &Script, input_index: usize) -> Result> { - let script_bytes = script.as_bytes(); - let mut pubkeys = Vec::new(); - let mut i = 0; - - while i < script_bytes.len() { - if script_bytes[i] == 33 && i + 33 < script_bytes.len() { - let pk_bytes = &script_bytes[i + 1..i + 34]; - if let Ok(pk) = PublicKey::from_slice(pk_bytes) { - pubkeys.push(pk); - } - i += 34; - } else { - i += 1; - } +fn extract_pubkeys_from_timelocked_2of2( + script: &Script, + input_index: usize, +) -> Result<[PublicKey; 2]> { + let instructions: Vec = script + .instructions() + .collect::, _>>() + .map_err(|e| anyhow!("Input {}: failed to parse script: {}", input_index, e))?; + + if instructions.len() != 13 { + return Err(anyhow!( + "Input {}: unexpected script length {} (expected 13)", + input_index, + instructions.len() + )); } - if pubkeys.is_empty() { + let pk1 = match &instructions[2] { + Instruction::PushBytes(push_bytes) => PublicKey::from_slice(push_bytes) + .map_err(|_| anyhow!("Input {}: invalid pk1", input_index))?, + _ => return Err(anyhow!("Input {}: expected pk1 at position 2", input_index)), + }; + + let pk2 = match &instructions[3] { + Instruction::PushBytes(push_bytes) => PublicKey::from_slice(push_bytes) + .map_err(|_| anyhow!("Input {}: invalid pk2", input_index))?, + _ => return Err(anyhow!("Input {}: expected pk2 at position 3", input_index)), + }; + + let recovery_pk = match &instructions[10] { + Instruction::PushBytes(push_bytes) => PublicKey::from_slice(push_bytes) + .map_err(|_| anyhow!("Input {}: invalid recovery pubkey", input_index))?, + _ => { + return Err(anyhow!( + "Input {}: expected recovery pubkey at position 10", + input_index + )); + } + }; + + if recovery_pk != pk2 { return Err(anyhow!( - "Input {} script contains no recognizable public keys", + "Input {}: recovery key does not match multisig user key", input_index )); } - Ok(pubkeys) + Ok([pk1, pk2]) } +// P2WSH normal spend witness: OP_0 OP_1 +// ^^^^ selects OP_IF branch fn finalize_p2wsh(tx: &mut Transaction, i: usize, input: &pset::Input) -> Result<()> { let witness_script = input .witness_script .as_ref() .ok_or_else(|| anyhow!("Input {} missing witness_script", i))?; - let sigs = extract_ordered_sigs(witness_script, &input.partial_sigs, i)?; - - let mut witness = vec![vec![]]; // OP_0 - witness.extend(sigs); - witness.push(witness_script.to_bytes()); + let witness = if input.partial_sigs.len() >= 2 { + // Normal cosign path: OP_IF branch + let sigs = extract_ordered_sigs(witness_script, &input.partial_sigs, i)?; + let mut w = vec![vec![]]; // OP_0 dummy for CHECKMULTISIG + w.extend(sigs); + w.push(vec![0x01]); // select OP_IF + w.push(witness_script.to_bytes()); + w + } else { + // Recovery path: OP_ELSE branch + let (_, sig) = input + .partial_sigs + .iter() + .next() + .ok_or_else(|| anyhow!("Input {} has no signatures", i))?; + vec![ + sig.to_vec(), + vec![], // select OP_ELSE + witness_script.to_bytes(), + ] + }; tx.input[i].witness.script_witness = witness; - Ok(()) } +// P2SH normal spend scriptSig: OP_0 OP_1 fn finalize_p2sh(tx: &mut Transaction, i: usize, input: &pset::Input) -> Result<()> { let redeem_script = input .redeem_script .as_ref() .ok_or_else(|| anyhow!("Input {} missing redeem_script", i))?; - let sigs = extract_ordered_sigs(redeem_script, &input.partial_sigs, i)?; + let builder = if input.partial_sigs.len() >= 2 { + // Normal cosign path: OP_IF branch + let sigs = extract_ordered_sigs(redeem_script, &input.partial_sigs, i)?; - let mut builder = Builder::new(); - builder = builder.push_opcode(elements::opcodes::all::OP_PUSHBYTES_0); - for sig in &sigs { - builder = builder.push_slice(sig); - } - builder = builder.push_slice(redeem_script.as_bytes()); + let mut builder = elements::script::Builder::new(); + builder = builder.push_slice(&[]); // OP_0 dummy + for sig in &sigs { + builder = builder.push_slice(sig); + } + builder.push_int(1).push_slice(redeem_script.as_bytes()) + } else { + // Recovery path: OP_ELSE branch + let (_, sig) = input + .partial_sigs + .iter() + .next() + .ok_or_else(|| anyhow!("Input {} has no signatures", i))?; + + elements::script::Builder::new() + .push_slice(&sig) + .push_int(0) + .push_slice(redeem_script.as_bytes()) + }; tx.input[i].script_sig = builder.into_script(); - Ok(()) } diff --git a/cli/src/main.rs b/cli/src/main.rs index d0f9db7..e645928 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -51,6 +51,10 @@ enum AddressCommands { #[arg(short = '2', long)] pubkey2: String, + /// Timelock (default value used otherwise) + #[arg(long)] + timelock: Option, + /// Network (elements, liquid, liquid_testnet, bitcoin, testnet, testnet4) #[arg(short, long, default_value = "elements")] network: String, @@ -59,18 +63,6 @@ enum AddressCommands { #[arg(short, long, default_value = "p2wsh")] type_: String, }, - - /// Generate a P2TR (Taproot) address from a single public key. - /// The key is used as the internal key and tweaked with the Simplicity CMR. - P2tr { - /// Public key in hex format (the tweaked co-signer key) - #[arg(short, long)] - pubkey: String, - - /// Network (elements, liquid, liquid_testnet) - #[arg(short, long, default_value = "elements")] - network: String, - }, } #[derive(Subcommand)] @@ -94,6 +86,10 @@ enum BtcTxCommands { /// Network (bitcoin, testnet) #[arg(short, long, default_value = "bitcoin")] network: String, + + /// Relative timelock in blocks for recovery path + #[arg(long)] + sequence: Option, }, /// Sign a PSBT with one secret key (for co-signing) @@ -112,7 +108,16 @@ enum BtcTxCommands { /// Redeem script in hex format #[arg(short, long)] - redeem_script: String, + redeem_script: Option, + + #[arg(short, long)] + cosigner_pubkey: Option, + + #[arg(short, long)] + timelock: Option, + + #[arg(long, default_value_t = false)] + recovery: bool, }, /// Finalize a PSBT into a broadcastable transaction @@ -142,6 +147,10 @@ enum TxCommands { /// Network (elements, liquid, liquid_testnet) #[arg(short, long, default_value = "elements")] network: String, + + /// Relative timelock in blocks for recovery path + #[arg(long)] + sequence: Option, }, /// Sign a PSET with one secret key (for co-signing) @@ -179,13 +188,17 @@ fn main() -> Result<()> { AddressCommands::Multisig { pubkey1, pubkey2, + timelock, network, type_, } => { - commands::address::multisig::execute(&pubkey1, &pubkey2, &network, &type_)?; - } - AddressCommands::P2tr { pubkey, network } => { - commands::address::p2tr::execute(&pubkey, &network)?; + commands::address::multisig::execute( + &pubkey1, + &pubkey2, + timelock.as_deref(), + &network, + &type_, + )?; } }, @@ -201,8 +214,15 @@ fn main() -> Result<()> { outputs, asset, network, + sequence, } => { - commands::tx::create::execute(&inputs, &outputs, asset.as_deref(), &network)?; + commands::tx::create::execute( + &inputs, + &outputs, + asset.as_deref(), + &network, + sequence, + )?; } TxCommands::Sign { @@ -224,8 +244,9 @@ fn main() -> Result<()> { inputs, outputs, network, + sequence, } => { - commands::btc_tx::create::execute(&inputs, &outputs, &network)?; + commands::btc_tx::create::execute(&inputs, &outputs, &network, sequence)?; } BtcTxCommands::Sign { @@ -233,8 +254,19 @@ fn main() -> Result<()> { secret_key, input_index, redeem_script, + cosigner_pubkey, + timelock, + recovery, } => { - commands::btc_tx::sign::execute(&psbt, &secret_key, input_index, &redeem_script)?; + commands::btc_tx::sign::execute( + &psbt, + &secret_key, + input_index, + redeem_script.as_deref(), + cosigner_pubkey.as_deref(), + timelock, + recovery, + )?; } BtcTxCommands::Finalize { psbt } => { diff --git a/core/src/utils.rs b/core/src/utils.rs index c43e9af..fd50b7a 100644 --- a/core/src/utils.rs +++ b/core/src/utils.rs @@ -1,5 +1,10 @@ -use hal_simplicity::simplicity::bitcoin; +use std::str::FromStr; +use std::sync::LazyLock; + +use hal_simplicity::bitcoin::KnownHrp; +use hal_simplicity::bitcoin::taproot::TaprootBuilder; use hal_simplicity::simplicity::elements::Script; +use hal_simplicity::simplicity::elements::script::Instruction; use hal_simplicity::simplicity::elements::{ self, bitcoin::PublicKey, @@ -7,6 +12,7 @@ use hal_simplicity::simplicity::elements::{ secp256k1_zkp::{Secp256k1, SecretKey, rand::rngs::OsRng}, }; use hal_simplicity::simplicity::hashes::{HashEngine, sha256}; +use hal_simplicity::simplicity::{ToXOnlyPubkey, bitcoin}; use thiserror::Error; @@ -15,6 +21,8 @@ use thiserror::Error; pub enum UtilsError { #[error("Expected 2 public keys for 2-of-2 multisig, got {0}")] InvalidPublicKeyCount(usize), + #[error(transparent)] + P2SHError(bitcoin::address::P2shError), } const SIMPLICITY_TAG_PREFIX: &[u8] = b"Simplicity\x1fCommitment\x1f"; @@ -24,6 +32,23 @@ const JETIV: sha256::Midstate = sha256::Midstate([ 0xf7, 0x40, 0xce, 0xaf, 0x64, 0x7f, 0x15, 0xb3, 0x8a, 0xed, 0x91, 0x68, 0x16, 0x3f, 0x92, 0x1b, ]); +/// Default value for timelock +pub const CSV_LOCK_DEFAULT_BLOCKS: u16 = u16::MAX; + +/// The standard NUMS internal key for Taproot outputs. +/// +/// Used as the internal key in P2TR outputs that enforce spending exclusively +/// via script-path. +/// +/// Reference: BIP-341 — +pub const UNSPENDABLE_KEY_P2TR: LazyLock = LazyLock::new(|| { + bitcoin::XOnlyPublicKey::from_str( + "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0", + ) + .unwrap() + .into() +}); + // Warning: The CMRs generated here does not follow the proper Simplicity specification. // // TODO(ivanlele): Build valid Simplicity in Haskell from which we can extract the true CMRs. @@ -109,11 +134,340 @@ impl From<&hal_simplicity::bitcoin::Script> for TransactionType { unimplemented!("Unsupported transaction type") } } +#[derive(Error, Debug)] +pub enum SigExtractionError { + #[error("Input {0}: failed to parse tapscript leaf")] + ParseFail(usize), + #[error("Input {0}: unexpected number of instructions; expected {1}, got {2}")] + ScriptLenMismatch(usize, usize, usize), + #[error("Input {0}: invalid xonly pk at position {1}")] + InvalidPubKey(usize, usize), + #[error("Input {0}: expected 32-byte xonly pk at position {1}")] + ExpectedPubkey(usize, usize), + #[error("Input {0}: recovery key does not match multisig user key")] + RecoveryKeyMismatch(usize), +} + +/// Represents a 2-of-2 multisig script with a CSV timelock recovery path. +/// +/// Used across P2WSH, P2SH, and P2TR spend types. The cosigner key should +/// already have the CMR tweak applied before being passed in. +pub struct TimelockMultisigScript { + pub cosigner_pk: PublicKey, + pub user_pk: PublicKey, + pub timelock: i64, +} + +impl TimelockMultisigScript { + /// Extracts the cosigner and user pubkeys from a timelocked 2-of-2 multisig + /// redeem script used in P2WSH/P2SH spends. + /// + /// Expected script structure: + /// ```text + /// OP_IF + /// OP_2 OP_2 OP_CHECKMULTISIG + /// OP_ELSE + /// OP_CSV OP_DROP OP_CHECKSIG + /// OP_ENDIF + /// ``` + /// + /// Returns `[cosigner_pk, user_pk]` + pub fn extract_pubkeys_from_timelocked_2of2( + script: &bitcoin::blockdata::script::Script, + input_index: usize, + ) -> Result<[PublicKey; 2], SigExtractionError> { + let instructions = script + .instructions() + .collect::, _>>() + .map_err(|_| SigExtractionError::ParseFail(input_index))?; + + if instructions.len() != 13 { + return Err(SigExtractionError::ScriptLenMismatch( + input_index, + 13, + instructions.len(), + )); + } + + let pk1 = match &instructions[2] { + bitcoin::blockdata::script::Instruction::PushBytes(push_bytes) => { + PublicKey::from_slice(push_bytes.as_bytes()) + .map_err(|_| SigExtractionError::InvalidPubKey(input_index, 1))? + } + _ => { + return Err(SigExtractionError::ExpectedPubkey(input_index, 1)); + } + }; + + let pk2 = match &instructions[3] { + bitcoin::blockdata::script::Instruction::PushBytes(push_bytes) => { + PublicKey::from_slice(push_bytes.as_bytes()) + .map_err(|_| SigExtractionError::InvalidPubKey(input_index, 2))? + } + _ => { + return Err(SigExtractionError::ExpectedPubkey(input_index, 2)); + } + }; + + let recovery_pk = match &instructions[10] { + bitcoin::blockdata::script::Instruction::PushBytes(push_bytes) => { + PublicKey::from_slice(push_bytes.as_bytes()) + .map_err(|_| SigExtractionError::InvalidPubKey(input_index, 10))? + } + _ => { + return Err(SigExtractionError::ExpectedPubkey(input_index, 10)); + } + }; + + if recovery_pk != pk2 { + return Err(SigExtractionError::RecoveryKeyMismatch(input_index)); + } + + Ok([pk1, pk2]) + } + + /// Extracts the cosigner and user x-only pubkeys from a P2TR multisig leaf. + /// + /// Expected script structure: + /// ```text + /// OP_CHECKSIG OP_CHECKSIGADD OP_2 OP_EQUAL + /// ``` + /// + /// Returns `[cosigner_xonly, user_xonly]` + pub fn extract_pubkeys_from_p2tr_multisig_leaf( + script: &bitcoin::blockdata::script::Script, + input_index: usize, + ) -> Result<[bitcoin::XOnlyPublicKey; 2], SigExtractionError> { + let instructions = script + .instructions() + .collect::, _>>() + .map_err(|_| SigExtractionError::ParseFail(input_index))?; + + // Expected: OP_CHECKSIG OP_CHECKSIGADD OP_2 OP_EQUAL + if instructions.len() != 6 { + return Err(SigExtractionError::ScriptLenMismatch( + input_index, + 6, + instructions.len(), + )); + } + + let pk1 = match &instructions[0] { + bitcoin::blockdata::script::Instruction::PushBytes(b) if b.len() == 32 => { + bitcoin::XOnlyPublicKey::from_slice(b.as_bytes()) + .map_err(|_| SigExtractionError::InvalidPubKey(input_index, 0))? + } + _ => return Err(SigExtractionError::ExpectedPubkey(input_index, 0)), + }; + + let pk2 = match &instructions[2] { + bitcoin::blockdata::script::Instruction::PushBytes(b) if b.len() == 32 => { + bitcoin::XOnlyPublicKey::from_slice(b.as_bytes()) + .map_err(|_| SigExtractionError::InvalidPubKey(input_index, 2))? + } + _ => return Err(SigExtractionError::ExpectedPubkey(input_index, 2)), + }; + + Ok([pk1, pk2]) + } + + pub fn new(cosigner_pk: PublicKey, user_pk: PublicKey, timelock: i64) -> Self { + Self { + cosigner_pk, + user_pk, + timelock, + } + } + + pub fn with_default_timelock(cosigner_pk: PublicKey, user_pk: PublicKey) -> Self { + Self::new(cosigner_pk, user_pk, CSV_LOCK_DEFAULT_BLOCKS as i64) + } + + /// Builds the Elements version of the timelocked 2-of-2 multisig redeem script for P2SH/P2WSH. + /// + /// Structure: + /// ```text + /// OP_IF + /// OP_2 OP_2 OP_CHECKMULTISIG + /// OP_ELSE + /// OP_CSV OP_DROP OP_CHECKSIG + /// OP_ENDIF + /// ``` + pub fn to_elements_script(&self) -> elements::script::Script { + elements::script::Builder::new() + .push_opcode(elements::opcodes::all::OP_IF) + .push_int(2) + .push_key(&self.cosigner_pk) + .push_key(&self.user_pk) + .push_int(2) + .push_opcode(elements::opcodes::all::OP_CHECKMULTISIG) + .push_opcode(elements::opcodes::all::OP_ELSE) + .push_int(self.timelock) + .push_opcode(elements::opcodes::all::OP_CSV) + .push_opcode(elements::opcodes::all::OP_DROP) + .push_key(&self.user_pk) + .push_opcode(elements::opcodes::all::OP_CHECKSIG) + .push_opcode(elements::opcodes::all::OP_ENDIF) + .into_script() + } + + /// Builds the Bitcoin version of the timelocked 2-of-2 multisig redeem script for P2SH/P2WSH. + /// + /// Structure: + /// ```text + /// OP_IF + /// OP_2 OP_2 OP_CHECKMULTISIG + /// OP_ELSE + /// OP_CSV OP_DROP OP_CHECKSIG + /// OP_ENDIF + /// ``` + pub fn to_bitcoin_script(&self) -> bitcoin::script::ScriptBuf { + bitcoin::script::Builder::new() + .push_opcode(bitcoin::opcodes::all::OP_IF) + .push_int(2) + .push_key(&self.cosigner_pk) + .push_key(&self.user_pk) + .push_int(2) + .push_opcode(bitcoin::opcodes::all::OP_CHECKMULTISIG) + .push_opcode(bitcoin::opcodes::all::OP_ELSE) + .push_int(self.timelock) + .push_opcode(bitcoin::opcodes::all::OP_CSV) + .push_opcode(bitcoin::opcodes::all::OP_DROP) + .push_key(&self.user_pk) + .push_opcode(bitcoin::opcodes::all::OP_CHECKSIG) + .push_opcode(bitcoin::opcodes::all::OP_ENDIF) + .into_script() + } + + /// Builds the P2TR multisig leaf script for the normal cooperative spend path. + /// Script structure: + /// ```text + /// OP_CHECKSIG OP_CHECKSIGADD OP_2 OP_EQUAL + /// ``` + /// + /// Witness order during finalization: `