From 2e0fb646b0f0370bf9b0331a0ab3644b2f08b6af Mon Sep 17 00:00:00 2001 From: ivanlele Date: Tue, 20 Jan 2026 15:03:17 +0200 Subject: [PATCH 1/2] Implement tweak endpoint and update demo script for public key generation --- cli/scripts/demo.sh | 28 ++++-- core/src/runner.rs | 6 +- service/config.toml | 2 +- service/src/handlers/mod.rs | 2 + service/src/handlers/sign.rs | 66 ++++++++----- service/src/handlers/tweak.rs | 173 ++++++++++++++++++++++++++++++++++ 6 files changed, 244 insertions(+), 33 deletions(-) create mode 100644 service/src/handlers/tweak.rs diff --git a/cli/scripts/demo.sh b/cli/scripts/demo.sh index dcadad8..16405c2 100755 --- a/cli/scripts/demo.sh +++ b/cli/scripts/demo.sh @@ -26,11 +26,27 @@ wait_for_transaction() { exit 1 } +# Simple Simplicity program that always returns true +PROGRAM="zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA" +WITNESS="" + NETWORK="liquid_testnet" -COSIGNER_PUBKEY="033523982d58e94be3b735731593f8225043880d53727235b566c515d24a0f7baf" -COSIGNER_SECKEY="804622cda0d8e634317a12651d91751ceff5c081f2b5f63ef7912725c7275e5d" FAUCET_ADDRESS="tlq1qq2g07nju42l0nlx0erqa3wsel2l8prnq96rlnhml262mcj7pe8w6ndvvyg237japt83z24m8gu4v3yfhaqvrqxydadc9scsmw" +echo "==== Step 0: Get Tweaked Public Key for Simplicity Program ====" +TWEAK_REQUEST=$(jq -n --arg program "$PROGRAM" '{program: $program}') + +TWEAK_RESPONSE=$(curl -s -X POST http://localhost:30431/simplicity-unchained/tweak \ + -H "Content-Type: application/json" \ + -d "$TWEAK_REQUEST") + +# Extract the tweaked public key from the response and set it as COSIGNER_PUBKEY +COSIGNER_PUBKEY=$(echo "$TWEAK_RESPONSE" | jq -r '.tweaked_public_key_hex') + +echo "Tweaked public key (Co-signer): $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') @@ -67,11 +83,7 @@ echo "Created PSET (unsigned): $PSET_HEX" echo echo "==== Step 5: First Signature (Co-signer) ====" -echo "Calling sign service at http://localhost:8080/simplicity-unchained/sign/pset..." - -# Simple Simplicity program that always returns true -PROGRAM="zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA" -WITNESS="" +echo "Calling sign service at http://localhost:30431/simplicity-unchained/sign/pset..." SIGN_REQUEST=$(jq -n \ --arg pset "$PSET_HEX" \ @@ -80,7 +92,7 @@ SIGN_REQUEST=$(jq -n \ --arg 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:8080/simplicity-unchained/sign/pset \ +PSET_SIGN1_DATA=$(curl -s -X POST http://localhost:30431/simplicity-unchained/sign/pset \ -H "Content-Type: application/json" \ -d "$SIGN_REQUEST") diff --git a/core/src/runner.rs b/core/src/runner.rs index 1db51e8..53f0246 100644 --- a/core/src/runner.rs +++ b/core/src/runner.rs @@ -60,7 +60,7 @@ impl SimplicityRunner { pset: &PartiallySignedTransaction, redeem_script: Script, network: Network, - ) -> Result<(), RunnerError> { + ) -> Result { let program = Program::::from_str(program, witness) .map_err(RunnerError::ProgramParse)?; @@ -81,7 +81,7 @@ impl SimplicityRunner { mac.exec(redeem_node, &env) .map_err(RunnerError::ExecutionError)?; - Ok(()) + Ok(program.commit_prog().cmr()) } } @@ -163,7 +163,7 @@ mod tests { let pset: PartiallySignedTransaction = deserialize(&pset_bytes).expect("valid PSET"); - SimplicityRunner::execute( + _ = SimplicityRunner::execute( program, Some(""), 0, diff --git a/service/config.toml b/service/config.toml index 9887ed8..2797853 100644 --- a/service/config.toml +++ b/service/config.toml @@ -1,6 +1,6 @@ [service] # Port on which the service will run -port = 8080 +port = 30431 # secpr256k1 32-byte hex-encoded private key, used for signing, # this is a demo key, do not use it anywhere else private_key = "804622cda0d8e634317a12651d91751ceff5c081f2b5f63ef7912725c7275e5d" diff --git a/service/src/handlers/mod.rs b/service/src/handlers/mod.rs index d8396fe..73a696a 100644 --- a/service/src/handlers/mod.rs +++ b/service/src/handlers/mod.rs @@ -1,4 +1,5 @@ pub mod sign; +pub mod tweak; use axum::{ Json, Router, @@ -20,6 +21,7 @@ pub fn routes(signer_state: sign::SignerState) -> Router { Router::new() .route("/simplicity-unchained/version", get(version)) .route("/simplicity-unchained/sign/pset", post(sign::sign_pset)) + .route("/simplicity-unchained/tweak", post(tweak::get_tweaked_key)) .with_state(signer_state) } diff --git a/service/src/handlers/sign.rs b/service/src/handlers/sign.rs index 17b4200..6a7c4a2 100644 --- a/service/src/handlers/sign.rs +++ b/service/src/handlers/sign.rs @@ -1,6 +1,13 @@ use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; -use hal_simplicity::simplicity::elements; +use hal_simplicity::{ + bitcoin::secp256k1, + simplicity::elements::{ + self, + schnorr::{TapTweak, UntweakedKeypair}, + taproot::TapNodeHash, + }, +}; use elements::{ EcdsaSighashType, @@ -118,7 +125,7 @@ fn sign_pset_internal( let redeem_script = Script::from(redeem_script_bytes); // Validate with Simplicity runner before signing - SimplicityRunner::execute( + let cmr = SimplicityRunner::execute( &request.program, request.witness.as_deref(), request.input_index, @@ -128,15 +135,19 @@ fn sign_pset_internal( ) .map_err(|e| format!("Simplicity execution failed: {}", e))?; - let public_key = PublicKey::from_private_key( + let untweaked_keypair = UntweakedKeypair::from_secret_key(&*state.secp, &state.secret_key); + let tweaked_keypair = untweaked_keypair.tap_tweak( &*state.secp, - &elements::bitcoin::PrivateKey { - compressed: true, - network: elements::bitcoin::NetworkKind::Main, - inner: state.secret_key, - }, + 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))?; @@ -159,7 +170,9 @@ fn sign_pset_internal( // Sign the sighash let msg = Message::from_digest(sighash.to_byte_array()); - let signature = state.secp.sign_ecdsa(&msg, &state.secret_key); + 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); @@ -193,9 +206,10 @@ mod tests { script::Builder as ScriptBuilder, secp256k1_zkp::Secp256k1, }; + use hal_simplicity::hal_simplicity::Program; use std::str::FromStr; - use simplicity_unchained_core::Network; + use simplicity_unchained_core::{Network, jets::unchained::ElementsExtension}; fn create_test_signer_state() -> SignerState { let secret_key = SecretKey::from_slice(&[0xcd; 32]).expect("valid secret key"); @@ -294,7 +308,7 @@ mod tests { input_index: 0, redeem_script_hex: hex::encode(redeem_script.as_bytes()), program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), - witness: None, + witness: Some("".to_string()), }; let result = sign_pset_internal(&state, request); @@ -431,12 +445,14 @@ mod tests { let pset_bytes = serialize(&pset); let pset_hex = hex::encode(&pset_bytes); + let program = "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(); + let request = SignPsetRequest { pset_hex, input_index: 0, redeem_script_hex: hex::encode(redeem_script.as_bytes()), - program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), - witness: None, + program: program.clone(), + witness: Some("".to_string()), }; let result = sign_pset_internal(&state, request).unwrap(); @@ -445,16 +461,24 @@ mod tests { let signed_pset_bytes = hex::decode(&result.pset_hex).unwrap(); let signed_pset: PartiallySignedTransaction = deserialize(&signed_pset_bytes).unwrap(); - // Get the signature from the PSET - let public_key = PublicKey::from_private_key( + let program = + Program::::from_str(&program, Some("")).expect("valid program"); + + 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, - &elements::bitcoin::PrivateKey { - compressed: true, - network: elements::bitcoin::NetworkKind::Main, - inner: state.secret_key, - }, + 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 sig_bytes = signed_pset.inputs()[0] .partial_sigs .get(&public_key) @@ -485,7 +509,7 @@ mod tests { let verification = state .secp - .verify_ecdsa(&msg, &signature, &state.secret_key.public_key(&*state.secp)); + .verify_ecdsa(&msg, &signature, &tweaked_keypair.to_inner().public_key()); assert!(verification.is_ok()); } diff --git a/service/src/handlers/tweak.rs b/service/src/handlers/tweak.rs new file mode 100644 index 0000000..b1443fa --- /dev/null +++ b/service/src/handlers/tweak.rs @@ -0,0 +1,173 @@ +use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; + +use hal_simplicity::{ + bitcoin::secp256k1, + hal_simplicity::Program, + simplicity::elements::{ + self, + schnorr::{TapTweak, UntweakedKeypair}, + taproot::TapNodeHash, + }, +}; + +use elements::{bitcoin::PublicKey, hashes::Hash}; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +use simplicity_unchained_core::jets::unchained::ElementsExtension; + +use crate::handlers::{ErrorResponse, sign::SignerState}; + +#[derive(Debug, Deserialize, Validate)] +pub struct TweakRequest { + #[validate(length(min = 1))] + pub program: String, +} + +#[derive(Debug, Serialize)] +pub struct TweakResponse { + pub cmr_hex: String, + pub tweaked_public_key_hex: String, +} + +pub async fn get_tweaked_key( + State(state): State, + Json(request): Json, +) -> impl IntoResponse { + // Validate request using validator + if let Err(errors) = request.validate() { + return ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: format!("Validation failed: {}", errors), + }), + ) + .into_response(); + } + + match get_tweaked_key_internal(&state, request) { + Ok(response) => (StatusCode::OK, Json(response)).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e })).into_response(), + } +} + +fn get_tweaked_key_internal( + state: &SignerState, + request: TweakRequest, +) -> Result { + // Parse Simplicity program and get CMR from commitment + let program = Program::::from_str(&request.program, None) + .map_err(|e| format!("Failed to parse program: {}", e))?; + + let cmr = program.commit_prog().cmr(); + + // Create untweaked keypair and tweak it with the 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())), + ); + + 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, + )); + + Ok(TweakResponse { + cmr_hex: hex::encode(cmr.to_byte_array()), + tweaked_public_key_hex: hex::encode(public_key.to_bytes()), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use elements::secp256k1_zkp::{Secp256k1, SecretKey}; + use std::sync::Arc; + + use simplicity_unchained_core::Network; + + fn create_test_signer_state() -> SignerState { + let secret_key = SecretKey::from_slice(&[0xcd; 32]).expect("valid secret key"); + SignerState { + secret_key, + secp: Arc::new(Secp256k1::new()), + network: Network::LiquidTestnet, + } + } + + #[test] + fn test_get_tweaked_key_internal_success() { + let state = create_test_signer_state(); + + let request = TweakRequest { + program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), + }; + + let result = get_tweaked_key_internal(&state, request); + assert!(result.is_ok()); + + let response = result.unwrap(); + assert!(!response.cmr_hex.is_empty()); + assert!(!response.tweaked_public_key_hex.is_empty()); + + // Verify CMR is 32 bytes (64 hex chars) + assert_eq!(response.cmr_hex.len(), 64); + + // Verify public key is 33 bytes (66 hex chars) for compressed key + assert_eq!(response.tweaked_public_key_hex.len(), 66); + } + + #[test] + fn test_get_tweaked_key_internal_invalid_program() { + let state = create_test_signer_state(); + + let request = TweakRequest { + program: "invalid_program".to_string(), + }; + + let result = get_tweaked_key_internal(&state, request); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Failed to parse program")); + } + + #[test] + fn test_tweak_request_validation() { + // Valid request + let valid_request = TweakRequest { + program: "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(), + }; + assert!(valid_request.validate().is_ok()); + + // Empty program should fail + let invalid_request = TweakRequest { + program: "".to_string(), + }; + assert!(invalid_request.validate().is_err()); + } + + #[test] + fn test_consistent_cmr_and_key_with_sign() { + // This test ensures the tweak endpoint produces the same CMR and key + // as the sign endpoint would use internally + let state = create_test_signer_state(); + + let program = "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA".to_string(); + + let request = TweakRequest { + program: program.clone(), + }; + + let result = get_tweaked_key_internal(&state, request).unwrap(); + + // Decode and verify the CMR + let cmr_bytes = hex::decode(&result.cmr_hex).unwrap(); + assert_eq!(cmr_bytes.len(), 32); + + // Decode and verify the public key + let pubkey_bytes = hex::decode(&result.tweaked_public_key_hex).unwrap(); + assert_eq!(pubkey_bytes.len(), 33); // Compressed public key + } +} From 94a897f339a765bd70fbcc649c688febab0b3f3b Mon Sep 17 00:00:00 2001 From: ivanlele Date: Tue, 20 Jan 2026 15:21:54 +0200 Subject: [PATCH 2/2] Add tweak endpoint to README --- service/README.md | 128 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 89 insertions(+), 39 deletions(-) diff --git a/service/README.md b/service/README.md index a25cebe..ebe6300 100644 --- a/service/README.md +++ b/service/README.md @@ -35,45 +35,95 @@ network = "liquidtestnet" The service exposes the following endpoints: -1. `POST /simplicity-unchained/sign/pset`: Accepts a Simplicity program and its inputs, executes it, and if successful, co-signs a 2-of-2 multisig transaction. - **Request Body**: - - ```json - { - "pset_hex": "70736574ff...", - "input_index": 0, - "redeem_script_hex": "5221...", - "program": "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA", - "witness": "" - } - ``` - - **Request Fields**: - - `pset_hex`: The PSET (Partially Signed Elements Transaction) encoded as a hexadecimal string - - `input_index`: The index of the transaction input to sign (must be between 0 and 65535) - - `redeem_script_hex`: The redeem script in hexadecimal format, used for SegWit v0 signature computation - - `program`: The Simplicity program to execute for validation before signing - - `witness`: The witness data required by the Simplicity program (can be an empty string) - - **Success Response**: - - ```json - { - "pset_hex": "70736574ff...", - "signature_hex": "3045022100...", - "public_key_hex": "02...", - "input_index": 0, - "partial_sigs_count": 1 - } - ``` - - **Error Response**: - - ```json - { - "error": "Validation failed: ..." - } - ``` +### 1. Version Endpoint + +`GET /simplicity-unchained/version`: Returns the current version of the service. + +**Success Response**: + +```json +{ + "version": "0.1.0" +} +``` + +### 2. Sign PSET Endpoint + +`POST /simplicity-unchained/sign/pset`: Accepts a Simplicity program and its inputs, executes it, and if successful, co-signs a 2-of-2 multisig transaction. +**Request Body**: + +```json +{ + "pset_hex": "70736574ff...", + "input_index": 0, + "redeem_script_hex": "5221...", + "program": "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA", + "witness": "" +} +``` + +**Request Fields**: +- `pset_hex`: The PSET (Partially Signed Elements Transaction) encoded as a hexadecimal string +- `input_index`: The index of the transaction input to sign (must be between 0 and 65535) +- `redeem_script_hex`: The redeem script in hexadecimal format, used for SegWit v0 signature computation +- `program`: The Simplicity program to execute for validation before signing +- `witness`: The witness data required by the Simplicity program (can be an empty string) + +**Success Response**: + +```json +{ + "pset_hex": "70736574ff...", + "signature_hex": "3045022100...", + "public_key_hex": "02...", + "input_index": 0, + "partial_sigs_count": 1 +} +``` + +**Error Response**: + +```json +{ + "error": "Validation failed: ..." +} +``` + +### 3. Tweak Endpoint + +`POST /simplicity-unchained/tweak`: Accepts a Simplicity program, computes its CMR (commitment Merkle root), and returns the tweaked public key using taproot key tweaking. + +**Request Body**: + +```json +{ + "program": "zSQIS29W33fvVt9371bfd+9W33fvVt9371bfd+9W33fvVt93hgGA" +} +``` + +**Request Fields**: +- `program`: The Simplicity program to compute the CMR from (must be a non-empty string) + +**Success Response**: + +```json +{ + "cmr_hex": "a9b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1", + "tweaked_public_key_hex": "02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" +} +``` + +**Response Fields**: +- `cmr_hex`: The 32-byte commitment Merkle root (CMR) of the program, encoded as a 64-character hexadecimal string +- `tweaked_public_key_hex`: The 33-byte compressed tweaked public key, encoded as a 66-character hexadecimal string + +**Error Response**: + +```json +{ + "error": "Failed to parse program: ..." +} +``` ## Licence