From 8314f89ebd8c3e068c4358fe68abc701079bdb72 Mon Sep 17 00:00:00 2001 From: topologoanatom Date: Wed, 18 Mar 2026 13:57:26 +0200 Subject: [PATCH 1/2] p2sh and p2tr for pset --- cli/scripts/demo.sh | 2 +- service/src/handlers/mod.rs | 20 +- service/src/handlers/sign_pset.rs | 354 +++++++++++++++++++++++++++--- 3 files changed, 344 insertions(+), 32 deletions(-) diff --git a/cli/scripts/demo.sh b/cli/scripts/demo.sh index 16405c2..87f6495 100755 --- a/cli/scripts/demo.sh +++ b/cli/scripts/demo.sh @@ -90,7 +90,7 @@ SIGN_REQUEST=$(jq -n \ --arg redeem "$REDEEM_SCRIPT" \ --arg program "$PROGRAM" \ --arg witness "$WITNESS" \ - '{pset_hex: $pset, redeem_script_hex: $redeem, input_index: 0, program: $program, witness: $witness}') + '{pset_hex: $pset, redeem_script_hex: $redeem, input_index: 0, spend_type: "P2WSH", program: $program, witness: $witness}') PSET_SIGN1_DATA=$(curl -s -X POST http://localhost:30431/simplicity-unchained/sign/pset \ -H "Content-Type: application/json" \ diff --git a/service/src/handlers/mod.rs b/service/src/handlers/mod.rs index 4e78817..d30ab45 100644 --- a/service/src/handlers/mod.rs +++ b/service/src/handlers/mod.rs @@ -2,7 +2,7 @@ pub mod sign_psbt; pub mod sign_pset; pub mod tweak; -use std::sync::Arc; +use std::{str::FromStr, sync::Arc}; use axum::{ Json, Router, @@ -39,6 +39,24 @@ pub fn routes(signer_state: SignerState) -> Router { .with_state(signer_state) } +pub enum SpendType { + P2SH, + P2WSH, + P2TR, +} + +impl FromStr for SpendType { + type Err = String; + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_ref() { + "p2sh" => Ok(Self::P2SH), + "p2wsh" => Ok(Self::P2WSH), + "p2tr" => Ok(Self::P2TR), + _ => Err("Unsupported spend type".to_string()), + } + } +} + #[derive(Clone, Debug)] pub struct SignerState { pub secret_key: SecretKey, diff --git a/service/src/handlers/sign_pset.rs b/service/src/handlers/sign_pset.rs index ebd27a4..2db0fb7 100644 --- a/service/src/handlers/sign_pset.rs +++ b/service/src/handlers/sign_pset.rs @@ -1,10 +1,12 @@ +use std::str::FromStr; + use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use hal_simplicity::{ bitcoin::secp256k1, simplicity::elements::{ - self, - schnorr::{TapTweak, UntweakedKeypair}, + self, SchnorrSighashType, Transaction, TxOut, + schnorr::{TapTweak, TweakedKeypair, UntweakedKeypair}, taproot::TapNodeHash, }, }; @@ -25,7 +27,7 @@ use validator::Validate; use simplicity_unchained_core::runner::SimplicityRunner; -use crate::handlers::ErrorResponse; +use crate::handlers::{ErrorResponse, SpendType}; use crate::validation; use super::SignerState; @@ -40,6 +42,7 @@ pub struct SignPsetRequest { u16::MAX as usize }))] pub input_index: usize, + pub spend_type: String, #[validate(custom(function = "validation::validate_redeem_script"))] pub redeem_script_hex: String, @@ -91,7 +94,6 @@ pub async fn sign_pset( } } } - fn sign_pset_internal( state: &SignerState, request: SignPsetRequest, @@ -110,12 +112,13 @@ fn sign_pset_internal( )); } + let spend_type = SpendType::from_str(&request.spend_type)?; + let redeem_script_bytes = hex::decode(&request.redeem_script_hex) .map_err(|e| format!("Failed to decode redeem script hex: {}", e))?; let redeem_script = Script::from(redeem_script_bytes); - // Validate with Simplicity runner before signing let cmr = SimplicityRunner::execute_elements( &request.program, request.witness.as_deref(), @@ -132,34 +135,100 @@ fn sign_pset_internal( Some(TapNodeHash::from_byte_array(cmr.to_byte_array())), ); - let (tweaked_public_key, tweaked_parity) = tweaked_keypair.public_parts(); - - let public_key = PublicKey::new(secp256k1::PublicKey::from_x_only_public_key( - tweaked_public_key.into_inner(), - tweaked_parity, - )); - let tx = pset .extract_tx() .map_err(|e| format!("Failed to extract transaction: {}", e))?; - let pset_input = &pset.inputs()[request.input_index]; - let prev_value = pset_input + let (sig_bytes, partial_sigs_count) = match spend_type { + SpendType::P2SH => { + let (tweaked_public_key, tweaked_parity) = tweaked_keypair.public_parts(); + let public_key = PublicKey::new(secp256k1::PublicKey::from_x_only_public_key( + tweaked_public_key.into_inner(), + tweaked_parity, + )); + let sig = sign_p2sh( + state, + &mut pset, + &tx, + &tweaked_keypair, + public_key, + &redeem_script, + request.input_index, + )?; + let count = pset.inputs()[request.input_index].partial_sigs.len(); + (sig, count) + } + SpendType::P2WSH => { + let (tweaked_public_key, tweaked_parity) = tweaked_keypair.public_parts(); + let public_key = PublicKey::new(secp256k1::PublicKey::from_x_only_public_key( + tweaked_public_key.into_inner(), + tweaked_parity, + )); + let sig = sign_p2wsh( + state, + &mut pset, + &tx, + &redeem_script, + public_key, + &tweaked_keypair, + request.input_index, + )?; + let count = pset.inputs()[request.input_index].partial_sigs.len(); + (sig, count) + } + SpendType::P2TR => { + let sig = sign_p2tr(state, &mut pset, &tx, &tweaked_keypair, request.input_index)?; + let count = pset.inputs()[request.input_index].partial_sigs.len(); + (sig, count) + } + }; + + let public_key_hex = match spend_type { + SpendType::P2TR => String::new(), + _ => { + let (tweaked_public_key, tweaked_parity) = tweaked_keypair.public_parts(); + let public_key = PublicKey::new(secp256k1::PublicKey::from_x_only_public_key( + tweaked_public_key.into_inner(), + tweaked_parity, + )); + hex::encode(public_key.to_bytes()) + } + }; + + Ok(SignPsetResponse { + pset_hex: hex::encode(serialize(&pset)), + // NOTE: For P2TR, sig_bytes is 64 bytes + // For P2SH/P2WSH it is DER-encoded ECDSA + 1 sighash byte. + signature_hex: hex::encode(&sig_bytes), + public_key_hex, + input_index: request.input_index, + partial_sigs_count, + }) +} + +fn sign_p2wsh( + state: &SignerState, + pset: &mut PartiallySignedTransaction, + tx: &Transaction, + redeem_script: &Script, + public_key: PublicKey, + tweaked_keypair: &TweakedKeypair, + input_index: usize, +) -> Result, String> { + let prev_value = pset.inputs()[input_index] .witness_utxo .as_ref() - .ok_or_else(|| format!("Missing witness UTXO for input {}", request.input_index))? + .ok_or_else(|| format!("Missing witness UTXO for input {}", input_index))? .value; - // Compute sighash for P2WSH (SegWit v0) - let mut sighash_cache = SighashCache::new(&tx); + let mut sighash_cache = SighashCache::new(tx); let sighash = sighash_cache.segwitv0_sighash( - request.input_index, - &redeem_script, + input_index, + redeem_script, prev_value, EcdsaSighashType::All, ); - // Sign the sighash let msg = Message::from_digest(sighash.to_byte_array()); let signature = state .secp @@ -168,22 +237,83 @@ fn sign_pset_internal( let mut sig_bytes = signature.serialize_der().to_vec(); sig_bytes.push(EcdsaSighashType::All.as_u32() as u8); - let input = &mut pset.inputs_mut()[request.input_index]; + let input = &mut pset.inputs_mut()[input_index]; input.partial_sigs.insert(public_key, sig_bytes.clone()); - if input.witness_script.is_none() { input.witness_script = Some(redeem_script.clone()); } - let partial_sigs_count = pset.inputs()[request.input_index].partial_sigs.len(); + Ok(sig_bytes) +} - Ok(SignPsetResponse { - pset_hex: hex::encode(serialize(&pset)), - signature_hex: hex::encode(&sig_bytes), - public_key_hex: hex::encode(public_key.to_bytes()), - input_index: request.input_index, - partial_sigs_count, - }) +fn sign_p2tr( + state: &SignerState, + pset: &mut PartiallySignedTransaction, + tx: &Transaction, + tweaked_keypair: &TweakedKeypair, + input_index: usize, +) -> Result, String> { + let prevouts: Vec = pset + .inputs() + .iter() + .map(|i| { + i.witness_utxo + .clone() + .ok_or_else(|| "Missing witness_utxo for taproot input".to_string()) + }) + .collect::>()?; + + let mut sighash_cache = SighashCache::new(tx); + let sighash = sighash_cache + .taproot_key_spend_signature_hash( + input_index, + &elements::sighash::Prevouts::All(&prevouts), + SchnorrSighashType::Default, + state.elements_network.genesis_hash(), + ) + .map_err(|e| format!("Failed to compute taproot sighash: {}", e))?; + + let msg = Message::from_digest(sighash.to_byte_array()); + let signature = state.secp.sign_schnorr(&msg, &tweaked_keypair.to_inner()); + + // 64-byte raw Schnorr signature + let sig_bytes = signature.as_ref().to_vec(); + + pset.inputs_mut()[input_index].tap_key_sig = Some(elements::SchnorrSig { + sig: signature, + hash_ty: SchnorrSighashType::Default, + }); + + Ok(sig_bytes) +} + +fn sign_p2sh( + state: &SignerState, + pset: &mut PartiallySignedTransaction, + tx: &Transaction, + tweaked_keypair: &TweakedKeypair, + public_key: PublicKey, + redeem_script: &Script, + input_index: usize, +) -> Result, String> { + let sighash_cache = SighashCache::new(tx); + let sighash = sighash_cache.legacy_sighash(input_index, redeem_script, EcdsaSighashType::All); + + let msg = Message::from_digest(sighash.to_byte_array()); + let signature = state + .secp + .sign_ecdsa(&msg, &tweaked_keypair.to_inner().secret_key()); + + let mut sig_bytes = signature.serialize_der().to_vec(); + sig_bytes.push(EcdsaSighashType::All.as_u32() as u8); + + let input = &mut pset.inputs_mut()[input_index]; + input.partial_sigs.insert(public_key, sig_bytes.clone()); + if input.redeem_script.is_none() { + input.redeem_script = Some(redeem_script.clone()); + } + + Ok(sig_bytes) } #[cfg(test)] @@ -304,6 +434,7 @@ mod tests { let request = SignPsetRequest { pset_hex, input_index: 0, + spend_type: "p2wsh".to_string(), redeem_script_hex: hex::encode(redeem_script.as_bytes()), program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), witness: Some("".to_string()), @@ -338,6 +469,83 @@ mod tests { assert!(signed_pset.inputs()[0].witness_script.is_some()); } + #[test] + fn test_sign_p2sh_success() { + let state = create_test_signer_state(); + let key2 = SecretKey::from_slice(&[0xab; 32]).expect("valid secret key"); + let redeem_script = create_2of2_multisig_script(&state, &key2); + + let tx = create_test_transaction(); + let pset = create_test_pset(tx); + let pset_hex = hex::encode(serialize(&pset)); + + let request = SignPsetRequest { + pset_hex, + input_index: 0, + spend_type: "p2sh".to_string(), + redeem_script_hex: hex::encode(redeem_script.as_bytes()), + program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), + witness: Some("".to_string()), + }; + + let result = sign_pset_internal(&state, request).unwrap(); + + // DER + sighash byte + let sig_bytes = hex::decode(&result.signature_hex).unwrap(); + assert_eq!( + *sig_bytes.last().unwrap(), + EcdsaSighashType::All.as_u32() as u8 + ); + assert!(!result.public_key_hex.is_empty()); + + let signed_pset_bytes = hex::decode(&result.pset_hex).unwrap(); + let signed_pset: PartiallySignedTransaction = deserialize(&signed_pset_bytes).unwrap(); + + // P2SH: redeem_script set, witness_script NOT set + assert!(signed_pset.inputs()[0].redeem_script.is_some()); + assert!(signed_pset.inputs()[0].witness_script.is_none()); + assert!(!signed_pset.inputs()[0].partial_sigs.is_empty()); + } + + #[test] + fn test_sign_p2tr_success() { + let state = create_test_signer_state(); + let key2 = SecretKey::from_slice(&[0xab; 32]).expect("valid secret key"); + let redeem_script = create_2of2_multisig_script(&state, &key2); + + let tx = create_test_transaction(); + let pset = create_test_pset(tx); + let pset_hex = hex::encode(serialize(&pset)); + + let request = SignPsetRequest { + pset_hex, + input_index: 0, + spend_type: "p2tr".to_string(), + // redeem_script is only used for Simplicity validation, not the spending script + redeem_script_hex: hex::encode(redeem_script.as_bytes()), + program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), + witness: Some("".to_string()), + }; + + let result = sign_pset_internal(&state, request).unwrap(); + + // P2TR returns 64-byte Schnorr sig, no sighash byte + let sig_bytes = hex::decode(&result.signature_hex).unwrap(); + assert_eq!(sig_bytes.len(), 64); + + assert!(result.public_key_hex.is_empty()); + + let signed_pset_bytes = hex::decode(&result.pset_hex).unwrap(); + let signed_pset: PartiallySignedTransaction = deserialize(&signed_pset_bytes).unwrap(); + + // tap_key_sig set, partial_sigs empty + let tap_sig = signed_pset.inputs()[0] + .tap_key_sig + .expect("tap_key_sig should be present"); + assert_eq!(tap_sig.hash_ty, SchnorrSighashType::Default); + assert!(signed_pset.inputs()[0].partial_sigs.is_empty()); + } + #[test] fn test_sign_pset_internal_invalid_hex() { let state = create_test_signer_state(); @@ -345,6 +553,7 @@ mod tests { let request = SignPsetRequest { pset_hex: "invalid_hex!!!".to_string(), input_index: 0, + spend_type: "p2wsh".to_string(), redeem_script_hex: "".to_string(), program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), witness: None, @@ -365,6 +574,7 @@ mod tests { let request = SignPsetRequest { pset_hex: invalid_data, input_index: 0, + spend_type: "p2wsh".to_string(), redeem_script_hex: "".to_string(), program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), witness: None, @@ -390,6 +600,7 @@ mod tests { let request = SignPsetRequest { pset_hex, input_index: 999, // Out of bounds + spend_type: "p2wsh".to_string(), redeem_script_hex: hex::encode(redeem_script.as_bytes()), program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), witness: None, @@ -417,6 +628,7 @@ mod tests { let request = SignPsetRequest { pset_hex, input_index: 0, + spend_type: "p2wsh".to_string(), redeem_script_hex: "invalid_hex!!!".to_string(), program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), witness: None, @@ -448,6 +660,7 @@ mod tests { let request = SignPsetRequest { pset_hex, input_index: 0, + spend_type: "p2wsh".to_string(), redeem_script_hex: hex::encode(redeem_script.as_bytes()), program: program.clone(), witness: Some("".to_string()), @@ -511,6 +724,65 @@ mod tests { assert!(verification.is_ok()); } + #[test] + fn test_sign_p2tr_signature_verification() { + let state = create_test_signer_state(); + let key2 = SecretKey::from_slice(&[0xab; 32]).expect("valid secret key"); + let redeem_script = create_2of2_multisig_script(&state, &key2); + + let tx = create_test_transaction(); + let pset = create_test_pset(tx.clone()); + let pset_hex = hex::encode(serialize(&pset)); + + let program = "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(); + + let request = SignPsetRequest { + pset_hex, + input_index: 0, + spend_type: "p2tr".to_string(), + redeem_script_hex: hex::encode(redeem_script.as_bytes()), + program: program.clone(), + witness: Some("".to_string()), + }; + + let result = sign_pset_internal(&state, request).unwrap(); + + let signed_pset_bytes = hex::decode(&result.pset_hex).unwrap(); + let signed_pset: PartiallySignedTransaction = deserialize(&signed_pset_bytes).unwrap(); + + let tap_sig = signed_pset.inputs()[0].tap_key_sig.unwrap(); + + // Reconstruct tweaked keypair the same way sign_pset_internal does + let program = Program::::from_str(&program, Some("")).unwrap(); + let cmr = program.commit_prog().cmr(); + + let untweaked_keypair = UntweakedKeypair::from_secret_key(&*state.secp, &state.secret_key); + let tweaked_keypair = untweaked_keypair.tap_tweak( + &*state.secp, + Some(TapNodeHash::from_byte_array(cmr.to_byte_array())), + ); + + // Recompute sighash + let prevouts = vec![signed_pset.inputs()[0].witness_utxo.clone().unwrap()]; + let mut sighash_cache = SighashCache::new(&tx); + let sighash = sighash_cache + .taproot_key_spend_signature_hash( + 0, + &elements::sighash::Prevouts::All(&prevouts), + SchnorrSighashType::Default, + state.elements_network.genesis_hash(), + ) + .unwrap(); + + let msg = Message::from_digest(sighash.to_byte_array()); + + // Verify with x-only pubkey — parity is stripped for P2TR + let (tweaked_xonly, _parity) = tweaked_keypair.public_parts(); + state + .secp + .verify_schnorr(&tap_sig.sig, &msg, &tweaked_xonly.into_inner()) + .expect("schnorr signature should be valid"); + } #[test] fn test_signer_state_new_valid_key() { @@ -557,6 +829,7 @@ mod tests { let valid_request = SignPsetRequest { pset_hex: "0000000000".to_string(), input_index: 0, + spend_type: "p2wsh".to_string(), redeem_script_hex: hex::encode(redeem_script.as_bytes()), program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), witness: None, @@ -567,10 +840,31 @@ mod tests { let invalid_request = SignPsetRequest { pset_hex: "".to_string(), input_index: 0, + spend_type: "p2wsh".to_string(), redeem_script_hex: hex::encode(redeem_script.as_bytes()), program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), witness: None, }; assert!(invalid_request.validate().is_err()); } + + #[test] + fn test_sign_pset_invalid_spend_type() { + let state = create_test_signer_state(); + let tx = create_test_transaction(); + let pset = create_test_pset(tx); + let pset_hex = hex::encode(serialize(&pset)); + + let request = SignPsetRequest { + pset_hex, + input_index: 0, + spend_type: "p2pkh".to_string(), // unsupported + redeem_script_hex: "abcd".to_string(), + program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), + witness: None, + }; + + let result = sign_pset_internal(&state, request); + assert!(result.is_err()); + } } From f5af01b48b05f7f813ab555f2e727f07e15c23e9 Mon Sep 17 00:00:00 2001 From: topologoanatom Date: Thu, 19 Mar 2026 16:35:35 +0200 Subject: [PATCH 2/2] derive spend type from script_pubkey --- cli/scripts/demo.sh | 2 +- service/src/handlers/mod.rs | 20 +-- service/src/handlers/sign_pset.rs | 239 ++++++++++++++---------------- 3 files changed, 115 insertions(+), 146 deletions(-) diff --git a/cli/scripts/demo.sh b/cli/scripts/demo.sh index 87f6495..16405c2 100755 --- a/cli/scripts/demo.sh +++ b/cli/scripts/demo.sh @@ -90,7 +90,7 @@ SIGN_REQUEST=$(jq -n \ --arg redeem "$REDEEM_SCRIPT" \ --arg program "$PROGRAM" \ --arg witness "$WITNESS" \ - '{pset_hex: $pset, redeem_script_hex: $redeem, input_index: 0, spend_type: "P2WSH", program: $program, witness: $witness}') + '{pset_hex: $pset, redeem_script_hex: $redeem, input_index: 0, program: $program, witness: $witness}') PSET_SIGN1_DATA=$(curl -s -X POST http://localhost:30431/simplicity-unchained/sign/pset \ -H "Content-Type: application/json" \ diff --git a/service/src/handlers/mod.rs b/service/src/handlers/mod.rs index d30ab45..4e78817 100644 --- a/service/src/handlers/mod.rs +++ b/service/src/handlers/mod.rs @@ -2,7 +2,7 @@ pub mod sign_psbt; pub mod sign_pset; pub mod tweak; -use std::{str::FromStr, sync::Arc}; +use std::sync::Arc; use axum::{ Json, Router, @@ -39,24 +39,6 @@ pub fn routes(signer_state: SignerState) -> Router { .with_state(signer_state) } -pub enum SpendType { - P2SH, - P2WSH, - P2TR, -} - -impl FromStr for SpendType { - type Err = String; - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_ref() { - "p2sh" => Ok(Self::P2SH), - "p2wsh" => Ok(Self::P2WSH), - "p2tr" => Ok(Self::P2TR), - _ => Err("Unsupported spend type".to_string()), - } - } -} - #[derive(Clone, Debug)] pub struct SignerState { pub secret_key: SecretKey, diff --git a/service/src/handlers/sign_pset.rs b/service/src/handlers/sign_pset.rs index 2db0fb7..22531a4 100644 --- a/service/src/handlers/sign_pset.rs +++ b/service/src/handlers/sign_pset.rs @@ -1,5 +1,3 @@ -use std::str::FromStr; - use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use hal_simplicity::{ @@ -27,7 +25,7 @@ use validator::Validate; use simplicity_unchained_core::runner::SimplicityRunner; -use crate::handlers::{ErrorResponse, SpendType}; +use crate::handlers::ErrorResponse; use crate::validation; use super::SignerState; @@ -42,7 +40,6 @@ pub struct SignPsetRequest { u16::MAX as usize }))] pub input_index: usize, - pub spend_type: String, #[validate(custom(function = "validation::validate_redeem_script"))] pub redeem_script_hex: String, @@ -112,8 +109,6 @@ fn sign_pset_internal( )); } - let spend_type = SpendType::from_str(&request.spend_type)?; - let redeem_script_bytes = hex::decode(&request.redeem_script_hex) .map_err(|e| format!("Failed to decode redeem script hex: {}", e))?; @@ -139,60 +134,57 @@ fn sign_pset_internal( .extract_tx() .map_err(|e| format!("Failed to extract transaction: {}", e))?; - let (sig_bytes, partial_sigs_count) = match spend_type { - SpendType::P2SH => { - let (tweaked_public_key, tweaked_parity) = tweaked_keypair.public_parts(); - let public_key = PublicKey::new(secp256k1::PublicKey::from_x_only_public_key( - tweaked_public_key.into_inner(), - tweaked_parity, - )); - let sig = sign_p2sh( - state, - &mut pset, - &tx, - &tweaked_keypair, - public_key, - &redeem_script, - request.input_index, - )?; - let count = pset.inputs()[request.input_index].partial_sigs.len(); - (sig, count) - } - SpendType::P2WSH => { - let (tweaked_public_key, tweaked_parity) = tweaked_keypair.public_parts(); - let public_key = PublicKey::new(secp256k1::PublicKey::from_x_only_public_key( - tweaked_public_key.into_inner(), - tweaked_parity, - )); - let sig = sign_p2wsh( - state, - &mut pset, - &tx, - &redeem_script, - public_key, - &tweaked_keypair, - request.input_index, - )?; - let count = pset.inputs()[request.input_index].partial_sigs.len(); - (sig, count) - } - SpendType::P2TR => { - let sig = sign_p2tr(state, &mut pset, &tx, &tweaked_keypair, request.input_index)?; - let count = pset.inputs()[request.input_index].partial_sigs.len(); - (sig, count) - } + // precalculate inside new scope to prevent script cloning because pset will be mutually borrowed + let (is_p2sh, is_p2wsh, is_p2tr) = { + let script_pubkey = &pset.inputs()[request.input_index] + .witness_utxo + .as_ref() + .ok_or_else(|| format!("Missing witness_utxo for input {}", request.input_index))? + .script_pubkey; + ( + script_pubkey.is_p2sh(), + script_pubkey.is_v0_p2wsh(), + script_pubkey.is_v1_p2tr(), + ) }; - let public_key_hex = match spend_type { - SpendType::P2TR => String::new(), - _ => { - let (tweaked_public_key, tweaked_parity) = tweaked_keypair.public_parts(); - let public_key = PublicKey::new(secp256k1::PublicKey::from_x_only_public_key( - tweaked_public_key.into_inner(), - tweaked_parity, - )); - hex::encode(public_key.to_bytes()) - } + let (sig_bytes, partial_sigs_count) = if is_p2sh || is_p2wsh { + let (tweaked_public_key, tweaked_parity) = tweaked_keypair.public_parts(); + let public_key = PublicKey::new(secp256k1::PublicKey::from_x_only_public_key( + tweaked_public_key.into_inner(), + tweaked_parity, + )); + + let sig = sign_p2wsh_p2sh( + is_p2sh, + state, + &mut pset, + &tx, + &redeem_script, + public_key, + &tweaked_keypair, + request.input_index, + )?; + + let count = pset.inputs()[request.input_index].partial_sigs.len(); + (sig, count) + } else if is_p2tr { + let sig = sign_p2tr(state, &mut pset, &tx, &tweaked_keypair, request.input_index)?; + let count = pset.inputs()[request.input_index].partial_sigs.len(); + (sig, count) + } else { + return Err("Unsupported script type".to_string()); + }; + + let public_key_hex = if is_p2tr { + String::new() + } else { + let (tweaked_public_key, tweaked_parity) = tweaked_keypair.public_parts(); + let public_key = PublicKey::new(secp256k1::PublicKey::from_x_only_public_key( + tweaked_public_key.into_inner(), + tweaked_parity, + )); + hex::encode(public_key.to_bytes()) }; Ok(SignPsetResponse { @@ -206,7 +198,8 @@ fn sign_pset_internal( }) } -fn sign_p2wsh( +fn sign_p2wsh_p2sh( + use_legacy: bool, state: &SignerState, pset: &mut PartiallySignedTransaction, tx: &Transaction, @@ -215,19 +208,24 @@ fn sign_p2wsh( tweaked_keypair: &TweakedKeypair, input_index: usize, ) -> Result, String> { - let prev_value = pset.inputs()[input_index] - .witness_utxo - .as_ref() - .ok_or_else(|| format!("Missing witness UTXO for input {}", input_index))? - .value; - - let mut sighash_cache = SighashCache::new(tx); - let sighash = sighash_cache.segwitv0_sighash( - input_index, - redeem_script, - prev_value, - EcdsaSighashType::All, - ); + let sighash = if use_legacy { + let sighash_cache = SighashCache::new(tx); + sighash_cache.legacy_sighash(input_index, redeem_script, EcdsaSighashType::All) + } else { + let prev_value = pset.inputs()[input_index] + .witness_utxo + .as_ref() + .ok_or_else(|| format!("Missing witness UTXO for input {}", input_index))? + .value; + + let mut sighash_cache = SighashCache::new(tx); + sighash_cache.segwitv0_sighash( + input_index, + redeem_script, + prev_value, + EcdsaSighashType::All, + ) + }; let msg = Message::from_digest(sighash.to_byte_array()); let signature = state @@ -239,8 +237,15 @@ fn sign_p2wsh( let input = &mut pset.inputs_mut()[input_index]; input.partial_sigs.insert(public_key, sig_bytes.clone()); - if input.witness_script.is_none() { - input.witness_script = Some(redeem_script.clone()); + + let input_script = if use_legacy { + &mut input.redeem_script + } else { + &mut input.witness_script + }; + + if input_script.is_none() { + *input_script = Some(redeem_script.clone()); } Ok(sig_bytes) @@ -287,35 +292,6 @@ fn sign_p2tr( Ok(sig_bytes) } -fn sign_p2sh( - state: &SignerState, - pset: &mut PartiallySignedTransaction, - tx: &Transaction, - tweaked_keypair: &TweakedKeypair, - public_key: PublicKey, - redeem_script: &Script, - input_index: usize, -) -> Result, String> { - let sighash_cache = SighashCache::new(tx); - let sighash = sighash_cache.legacy_sighash(input_index, redeem_script, EcdsaSighashType::All); - - let msg = Message::from_digest(sighash.to_byte_array()); - let signature = state - .secp - .sign_ecdsa(&msg, &tweaked_keypair.to_inner().secret_key()); - - let mut sig_bytes = signature.serialize_der().to_vec(); - sig_bytes.push(EcdsaSighashType::All.as_u32() as u8); - - let input = &mut pset.inputs_mut()[input_index]; - input.partial_sigs.insert(public_key, sig_bytes.clone()); - if input.redeem_script.is_none() { - input.redeem_script = Some(redeem_script.clone()); - } - - Ok(sig_bytes) -} - #[cfg(test)] mod tests { use std::sync::Arc; @@ -329,7 +305,7 @@ mod tests { script::Builder as ScriptBuilder, secp256k1_zkp::Secp256k1, }; - use hal_simplicity::hal_simplicity::Program; + use hal_simplicity::{bitcoin::hashes::Hash, hal_simplicity::Program}; use std::str::FromStr; use hal_simplicity::simplicity::elements::secp256k1_zkp::SecretKey; @@ -404,7 +380,7 @@ mod tests { } } - fn create_test_pset(tx: Transaction) -> PartiallySignedTransaction { + fn create_test_pset(tx: Transaction, script_pubkey: Script) -> PartiallySignedTransaction { let mut pset = PartiallySignedTransaction::from_tx(tx); // Add witness_utxo to the first input (required for SegWit v0) @@ -412,7 +388,7 @@ mod tests { asset: Asset::Explicit(elements::AssetId::from_slice(&[0; 32]).unwrap()), value: Value::Explicit(100_000), nonce: elements::confidential::Nonce::Null, - script_pubkey: Script::new(), + script_pubkey, witness: Default::default(), }); @@ -426,7 +402,8 @@ mod tests { let redeem_script = create_2of2_multisig_script(&state, &key2); let tx = create_test_transaction(); - let pset = create_test_pset(tx); + let script_pubkey = Script::new_v0_wsh(&redeem_script.to_v0_p2wsh().wscript_hash()); + let pset = create_test_pset(tx, script_pubkey); let pset_bytes = serialize(&pset); let pset_hex = hex::encode(&pset_bytes); @@ -434,7 +411,6 @@ mod tests { let request = SignPsetRequest { pset_hex, input_index: 0, - spend_type: "p2wsh".to_string(), redeem_script_hex: hex::encode(redeem_script.as_bytes()), program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), witness: Some("".to_string()), @@ -476,13 +452,13 @@ mod tests { let redeem_script = create_2of2_multisig_script(&state, &key2); let tx = create_test_transaction(); - let pset = create_test_pset(tx); + let script_pubkey = Script::new_p2sh(&redeem_script.script_hash()); + let pset = create_test_pset(tx, script_pubkey); let pset_hex = hex::encode(serialize(&pset)); let request = SignPsetRequest { pset_hex, input_index: 0, - spend_type: "p2sh".to_string(), redeem_script_hex: hex::encode(redeem_script.as_bytes()), program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), witness: Some("".to_string()), @@ -514,13 +490,19 @@ mod tests { let redeem_script = create_2of2_multisig_script(&state, &key2); let tx = create_test_transaction(); - let pset = create_test_pset(tx); + + let secret_key = SecretKey::from_slice(&[0xcd; 32]).unwrap(); + let secp = Secp256k1::new(); + let keypair = UntweakedKeypair::from_secret_key(&secp, &secret_key); + let (xonly, _) = keypair.x_only_public_key(); + let script_pubkey = Script::new_v1_p2tr(&secp, xonly, None); + + let pset = create_test_pset(tx, script_pubkey); let pset_hex = hex::encode(serialize(&pset)); let request = SignPsetRequest { pset_hex, input_index: 0, - spend_type: "p2tr".to_string(), // redeem_script is only used for Simplicity validation, not the spending script redeem_script_hex: hex::encode(redeem_script.as_bytes()), program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), @@ -553,7 +535,6 @@ mod tests { let request = SignPsetRequest { pset_hex: "invalid_hex!!!".to_string(), input_index: 0, - spend_type: "p2wsh".to_string(), redeem_script_hex: "".to_string(), program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), witness: None, @@ -574,7 +555,6 @@ mod tests { let request = SignPsetRequest { pset_hex: invalid_data, input_index: 0, - spend_type: "p2wsh".to_string(), redeem_script_hex: "".to_string(), program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), witness: None, @@ -592,7 +572,9 @@ mod tests { let redeem_script = create_2of2_multisig_script(&state, &key2); let tx = create_test_transaction(); - let pset = create_test_pset(tx); + + let script_pubkey = Script::new_v0_wsh(&redeem_script.to_v0_p2wsh().wscript_hash()); + let pset = create_test_pset(tx, script_pubkey); let pset_bytes = serialize(&pset); let pset_hex = hex::encode(&pset_bytes); @@ -600,7 +582,6 @@ mod tests { let request = SignPsetRequest { pset_hex, input_index: 999, // Out of bounds - spend_type: "p2wsh".to_string(), redeem_script_hex: hex::encode(redeem_script.as_bytes()), program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), witness: None, @@ -620,7 +601,9 @@ mod tests { let state = create_test_signer_state(); let tx = create_test_transaction(); - let pset = create_test_pset(tx); + + let script_pubkey = Script::new_v0_wsh(&Script::default().to_v0_p2wsh().wscript_hash()); + let pset = create_test_pset(tx, script_pubkey); let pset_bytes = serialize(&pset); let pset_hex = hex::encode(&pset_bytes); @@ -628,7 +611,6 @@ mod tests { let request = SignPsetRequest { pset_hex, input_index: 0, - spend_type: "p2wsh".to_string(), redeem_script_hex: "invalid_hex!!!".to_string(), program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), witness: None, @@ -650,7 +632,8 @@ mod tests { let redeem_script = create_2of2_multisig_script(&state, &key2); let tx = create_test_transaction(); - let pset = create_test_pset(tx.clone()); + let script_pubkey = Script::new_v0_wsh(&redeem_script.to_v0_p2wsh().wscript_hash()); + let pset = create_test_pset(tx.clone(), script_pubkey); let pset_bytes = serialize(&pset); let pset_hex = hex::encode(&pset_bytes); @@ -660,7 +643,6 @@ mod tests { let request = SignPsetRequest { pset_hex, input_index: 0, - spend_type: "p2wsh".to_string(), redeem_script_hex: hex::encode(redeem_script.as_bytes()), program: program.clone(), witness: Some("".to_string()), @@ -731,7 +713,14 @@ mod tests { let redeem_script = create_2of2_multisig_script(&state, &key2); let tx = create_test_transaction(); - let pset = create_test_pset(tx.clone()); + + let secret_key = SecretKey::from_slice(&[0xcd; 32]).unwrap(); + let secp = Secp256k1::new(); + let keypair = UntweakedKeypair::from_secret_key(&secp, &secret_key); + let (xonly, _) = keypair.x_only_public_key(); + let script_pubkey = Script::new_v1_p2tr(&secp, xonly, None); + + let pset = create_test_pset(tx.clone(), script_pubkey); let pset_hex = hex::encode(serialize(&pset)); let program = "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(); @@ -739,7 +728,6 @@ mod tests { let request = SignPsetRequest { pset_hex, input_index: 0, - spend_type: "p2tr".to_string(), redeem_script_hex: hex::encode(redeem_script.as_bytes()), program: program.clone(), witness: Some("".to_string()), @@ -829,7 +817,6 @@ mod tests { let valid_request = SignPsetRequest { pset_hex: "0000000000".to_string(), input_index: 0, - spend_type: "p2wsh".to_string(), redeem_script_hex: hex::encode(redeem_script.as_bytes()), program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), witness: None, @@ -840,7 +827,6 @@ mod tests { let invalid_request = SignPsetRequest { pset_hex: "".to_string(), input_index: 0, - spend_type: "p2wsh".to_string(), redeem_script_hex: hex::encode(redeem_script.as_bytes()), program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), witness: None, @@ -852,13 +838,14 @@ mod tests { fn test_sign_pset_invalid_spend_type() { let state = create_test_signer_state(); let tx = create_test_transaction(); - let pset = create_test_pset(tx); + let script_pubkey = + Script::new_p2pkh(&hal_simplicity::simplicity::elements::PubkeyHash::all_zeros()); + let pset = create_test_pset(tx, script_pubkey); let pset_hex = hex::encode(serialize(&pset)); let request = SignPsetRequest { pset_hex, input_index: 0, - spend_type: "p2pkh".to_string(), // unsupported redeem_script_hex: "abcd".to_string(), program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), witness: None,