diff --git a/crates/contracts/README.md b/crates/contracts/README.md index 94e8c53..c20236f 100644 --- a/crates/contracts/README.md +++ b/crates/contracts/README.md @@ -15,6 +15,9 @@ Current contract modules in this crate: - [Options](src/programs/options.rs) - [Options Offer](src/programs/option_offer.rs) +- Signatures: + - [Bitcoin Message ECDSA Verify](src/programs/bitcoin_message_ecdsa_verify/mod.rs) + ## License Dual-licensed under either of: diff --git a/crates/contracts/src/programs/bitcoin_message_ecdsa_verify/build_witness.rs b/crates/contracts/src/programs/bitcoin_message_ecdsa_verify/build_witness.rs new file mode 100644 index 0000000..c6da5b2 --- /dev/null +++ b/crates/contracts/src/programs/bitcoin_message_ecdsa_verify/build_witness.rs @@ -0,0 +1,43 @@ +use std::collections::HashMap; + +use simplex::simplicityhl::num::U256; +use simplex::simplicityhl::str::WitnessName; +use simplex::simplicityhl::value::{UIntValue, ValueConstructible}; +use simplex::simplicityhl::{Arguments, Value, WitnessValues}; + +use super::{BitcoinMessageEcdsaVerifyWitness, Point}; + +#[must_use] +pub fn build_bitcoin_message_ecdsa_verify_arguments(public_key: Point) -> Arguments { + Arguments::from(HashMap::from([( + WitnessName::from_str_unchecked("PUBLIC_KEY"), + point_value(public_key), + )])) +} + +#[must_use] +pub fn build_bitcoin_message_ecdsa_verify_witness( + witness: &BitcoinMessageEcdsaVerifyWitness, +) -> WitnessValues { + WitnessValues::from(HashMap::from([ + ( + WitnessName::from_str_unchecked("NONCE_POINT"), + point_value(witness.nonce_point), + ), + ( + WitnessName::from_str_unchecked("R"), + Value::from(UIntValue::U256(U256::from_byte_array(witness.r))), + ), + ( + WitnessName::from_str_unchecked("S"), + Value::from(UIntValue::U256(U256::from_byte_array(witness.s))), + ), + ])) +} + +fn point_value(point: Point) -> Value { + Value::tuple([ + Value::from(UIntValue::U1(u8::from(point.0))), + Value::from(UIntValue::U256(U256::from_byte_array(point.1))), + ]) +} diff --git a/crates/contracts/src/programs/bitcoin_message_ecdsa_verify/mod.rs b/crates/contracts/src/programs/bitcoin_message_ecdsa_verify/mod.rs new file mode 100644 index 0000000..ffb9e12 --- /dev/null +++ b/crates/contracts/src/programs/bitcoin_message_ecdsa_verify/mod.rs @@ -0,0 +1,324 @@ +use std::sync::Arc; + +use crate::error::ProgramError; +use crate::runner::run_program; +use crate::scripts::{control_block, create_p2tr_address, load_program}; + +use simplex::provider::SimplicityNetwork; +use simplex::simplicityhl::elements::bitcoin::secp256k1::PublicKey; +use simplex::simplicityhl::elements::hashes::Hash as _; +use simplex::simplicityhl::elements::{Address, Script, Transaction, TxInWitness, TxOut}; +use simplex::simplicityhl::simplicity::RedeemNode; +use simplex::simplicityhl::simplicity::bitcoin::XOnlyPublicKey; +use simplex::simplicityhl::simplicity::jet::Elements; +use simplex::simplicityhl::simplicity::jet::elements::{ElementsEnv, ElementsUtxo}; +use simplex::simplicityhl::tracker::TrackerLogLevel; +use simplex::simplicityhl::{CompiledProgram, WitnessValues}; +use simplex::utils::hash_script; + +mod build_witness; + +pub use build_witness::{ + build_bitcoin_message_ecdsa_verify_arguments, build_bitcoin_message_ecdsa_verify_witness, +}; + +pub type Point = (bool, [u8; 32]); +pub type Scalar = [u8; 32]; + +pub const BITCOIN_MESSAGE_ECDSA_VERIFY_SOURCE: &str = + include_str!("source_simf/bitcoin_message_ecdsa_verify.simf"); + +#[derive(Debug, Clone, Copy)] +pub struct BitcoinMessageEcdsaVerifyParameters { + pub public_key: Point, + pub network: SimplicityNetwork, +} + +#[derive(Debug, Clone, Copy)] +pub struct BitcoinMessageEcdsaVerifyWitness { + pub nonce_point: Point, + pub r: Scalar, + pub s: Scalar, +} + +pub struct BitcoinMessageEcdsaVerify { + compiled_program: CompiledProgram, + internal_key: XOnlyPublicKey, + pub parameters: BitcoinMessageEcdsaVerifyParameters, +} + +impl BitcoinMessageEcdsaVerify { + /// Compile the Bitcoin signed-message ECDSA verification contract. + /// + /// # Errors + /// + /// Returns an error if the embedded SIMF source cannot be compiled. + pub fn new(parameters: BitcoinMessageEcdsaVerifyParameters) -> Result { + Self::from_internal_key(unspendable_internal_key(), parameters) + } + + /// Compile the contract using an explicit Taproot internal key. + /// + /// # Errors + /// + /// Returns an error if the embedded SIMF source cannot be compiled. + pub fn from_internal_key( + internal_key: XOnlyPublicKey, + parameters: BitcoinMessageEcdsaVerifyParameters, + ) -> Result { + let compiled_program = load_program( + BITCOIN_MESSAGE_ECDSA_VERIFY_SOURCE, + build_bitcoin_message_ecdsa_verify_arguments(parameters.public_key), + )?; + + Ok(Self { + compiled_program, + internal_key, + parameters, + }) + } + + /// Compile the contract from a compressed ECDSA public key. + /// + /// # Errors + /// + /// Returns an error if the embedded SIMF source cannot be compiled. + pub fn from_public_key( + public_key: &PublicKey, + network: SimplicityNetwork, + ) -> Result { + Self::new(BitcoinMessageEcdsaVerifyParameters { + public_key: Self::point_from_public_key(public_key), + network, + }) + } + + /// Compile the contract from an explicit internal key and compressed ECDSA public key. + /// + /// # Errors + /// + /// Returns an error if the embedded SIMF source cannot be compiled. + pub fn from_internal_key_and_public_key( + internal_key: XOnlyPublicKey, + public_key: &PublicKey, + network: SimplicityNetwork, + ) -> Result { + Self::from_internal_key( + internal_key, + BitcoinMessageEcdsaVerifyParameters { + public_key: Self::point_from_public_key(public_key), + network, + }, + ) + } + + /// Convert a compressed ECDSA public key into the contract point representation. + /// + /// # Panics + /// + /// Panics only if `PublicKey::serialize` stops returning the standard + /// 33-byte compressed encoding. + #[must_use] + pub fn point_from_public_key(public_key: &PublicKey) -> Point { + let serialized = public_key.serialize(); + (serialized[0] == 0x03, serialized[1..].try_into().unwrap()) + } + + #[must_use] + pub const fn get_witness( + nonce_y_is_odd: bool, + r: Scalar, + s: Scalar, + ) -> BitcoinMessageEcdsaVerifyWitness { + BitcoinMessageEcdsaVerifyWitness { + nonce_point: (nonce_y_is_odd, r), + r, + s, + } + } + + #[must_use] + pub const fn get_program(&self) -> &CompiledProgram { + &self.compiled_program + } + + #[must_use] + pub const fn internal_key(&self) -> XOnlyPublicKey { + self.internal_key + } + + #[must_use] + pub fn get_address(&self) -> Address { + create_p2tr_address( + self.compiled_program.commit().cmr(), + &self.internal_key, + self.parameters.network.address_params(), + ) + } + + #[must_use] + pub fn get_script_pubkey(&self) -> Script { + self.get_address().script_pubkey() + } + + #[must_use] + pub fn get_script_hash(&self) -> [u8; 32] { + hash_script(&self.get_script_pubkey()) + } + + /// Build and verify the Elements environment for this contract input. + /// + /// # Errors + /// + /// Returns an error if the selected UTXO is missing, has a mismatched script + /// pubkey, or the input index does not fit in the Simplicity environment. + pub fn get_env( + &self, + tx: &Transaction, + utxos: &[TxOut], + input_index: usize, + ) -> Result>, ProgramError> { + let cmr = self.compiled_program.commit().cmr(); + + if utxos.len() <= input_index { + return Err(ProgramError::UtxoIndexOutOfBounds { + input_index, + utxo_count: utxos.len(), + }); + } + + let target_utxo = &utxos[input_index]; + let script_pubkey = self.get_script_pubkey(); + + if target_utxo.script_pubkey != script_pubkey { + return Err(ProgramError::ScriptPubkeyMismatch { + expected_hash: script_pubkey.script_hash().to_string(), + actual_hash: target_utxo.script_pubkey.script_hash().to_string(), + }); + } + + Ok(ElementsEnv::new( + Arc::new(tx.clone()), + utxos + .iter() + .map(|utxo| ElementsUtxo { + script_pubkey: utxo.script_pubkey.clone(), + asset: utxo.asset, + value: utxo.value, + }) + .collect(), + u32::try_from(input_index)?, + cmr, + control_block(cmr, self.internal_key), + None, + self.parameters.network.genesis_block_hash(), + )) + } + + /// Compute the Simplicity `sighash_all` used by the Bitcoin signed-message digest. + /// + /// # Errors + /// + /// Returns an error if environment construction fails. + pub fn sighash_all( + &self, + tx: &Transaction, + utxos: &[TxOut], + input_index: usize, + ) -> Result<[u8; 32], ProgramError> { + Ok(self + .get_env(tx, utxos, input_index)? + .c_tx_env() + .sighash_all() + .to_byte_array()) + } + + /// Execute this contract with an already-built environment. + /// + /// # Errors + /// + /// Returns an error if witness satisfaction, pruning, or execution fails. + pub fn execute( + &self, + witness: &BitcoinMessageEcdsaVerifyWitness, + env: &ElementsEnv>, + log_level: TrackerLogLevel, + ) -> Result>, ProgramError> { + self.execute_witness_values( + build_bitcoin_message_ecdsa_verify_witness(witness), + env, + log_level, + ) + } + + /// Execute this contract with manually supplied witness values. + /// + /// # Errors + /// + /// Returns an error if witness satisfaction, pruning, or execution fails. + pub fn execute_witness_values( + &self, + witness_values: WitnessValues, + env: &ElementsEnv>, + log_level: TrackerLogLevel, + ) -> Result>, ProgramError> { + Ok(run_program(&self.compiled_program, witness_values, env, log_level)?.0) + } + + /// Finalize a transaction input with this contract witness. + /// + /// # Errors + /// + /// Returns an error if environment construction or program execution fails. + pub fn finalize_transaction( + &self, + mut tx: Transaction, + utxos: &[TxOut], + input_index: usize, + witness: &BitcoinMessageEcdsaVerifyWitness, + log_level: TrackerLogLevel, + ) -> Result { + let env = self.get_env(&tx, utxos, input_index)?; + let pruned = self.execute(witness, &env, log_level)?; + + let (simplicity_program_bytes, simplicity_witness_bytes) = pruned.to_vec_with_witness(); + let cmr = pruned.cmr(); + let tx_input_count = tx.input.len(); + let tx_input = tx + .input + .get_mut(input_index) + .ok_or(ProgramError::UtxoIndexOutOfBounds { + input_index, + utxo_count: tx_input_count, + })?; + + tx_input.witness = TxInWitness { + amount_rangeproof: None, + inflation_keys_rangeproof: None, + script_witness: vec![ + simplicity_witness_bytes, + simplicity_program_bytes, + cmr.as_ref().to_vec(), + control_block(cmr, self.internal_key).serialize(), + ], + pegin_witness: vec![], + }; + + Ok(tx) + } +} + +/// The unspendable internal key specified in BIP-0341. +/// +/// # Panics +/// +/// Panics if the hard-coded key bytes stop parsing as an x-only public key. +#[rustfmt::skip] +#[must_use] +pub fn unspendable_internal_key() -> XOnlyPublicKey { + XOnlyPublicKey::from_slice(&[ + 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, 0x7a, 0x5e, + 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, 0xee, 0x9a, 0xce, 0x80, 0x3a, 0xc0, + ]) + .expect("key should be valid") +} diff --git a/crates/contracts/src/programs/bitcoin_message_ecdsa_verify/source_simf/bitcoin_message_ecdsa_verify.simf b/crates/contracts/src/programs/bitcoin_message_ecdsa_verify/source_simf/bitcoin_message_ecdsa_verify.simf new file mode 100644 index 0000000..b8c072f --- /dev/null +++ b/crates/contracts/src/programs/bitcoin_message_ecdsa_verify/source_simf/bitcoin_message_ecdsa_verify.simf @@ -0,0 +1,275 @@ +// ECDSA scalar guard. +// +// A Simplicity Scalar is represented as u256. For ECDSA r/s we need: +// 1. canonical scalar representative, i.e. reject values >= group order n; +// 2. nonzero, i.e. reject 0 because r = 0 is invalid and s^-1 is undefined. +fn checked_ecdsa_scalar(scalar: Scalar) -> Scalar { + // CANON: scalar_norm = scalar mod n in canonical representation. + let scalar_norm: Scalar = jet::scalar_normalize(scalar); + + // REJECT: non-canonical encodings such as n, n + 1, ... + assert!(jet::eq_256(::into(scalar_norm), ::into(scalar))); + + // REJECT: scalar == 0. + assert!(jet::eq_1(::into(jet::scalar_is_zero(scalar_norm)), 0)); + + // PASS: scalar is now guaranteed to be in [1, n - 1]. + scalar_norm +} + +// Field-element guard for compressed point x-coordinates. +// +// A Point is (y_is_odd, x). The x field is also represented as u256, so require +// the canonical field representative when accepting a witness point. +fn checked_fe(fe: Fe) -> Fe { + // CANON: fe_norm = fe mod p in canonical field representation. + let fe_norm: Fe = jet::fe_normalize(fe); + + // REJECT: non-canonical field encodings. + assert!(jet::eq_256(::into(fe_norm), ::into(fe))); + + // PASS: fe is now canonical. + fe_norm +} + +// Bind the supplied ECDSA nonce point to r. +// +// Standard ECDSA verification is not only: +// nonce == (r / s) * pubkey + (z / s) * G +// +// It also requires: +// r == x(nonce) mod n +// +// Without this check, the witness can provide an arbitrary nonce point matching +// the linear combination for attacker-chosen r/s. +fn ensure_nonce_x_matches_r(nonce: Point, r: Scalar) { + // LOAD: compressed nonce point = (y parity, affine x). + let (nonce_y_is_odd, nonce_x): (u1, Fe) = nonce; + + // CANON: reject non-canonical x-coordinate encodings. + let nonce_x_norm: Fe = checked_fe(nonce_x); + + // REDUCE: ECDSA compares r against x(R) reduced modulo group order n. + // This matters for rare x-coordinates where canonical field x >= n. + let nonce_x_as_scalar: Scalar = nonce_x_norm; + let nonce_x_mod_n: Scalar = jet::scalar_normalize(nonce_x_as_scalar); + + // REJECT: supplied r does not match x(nonce) mod n. + assert!(jet::eq_256(::into(nonce_x_mod_n), ::into(r))); +} + +// SHA256 of exactly 32 bytes. +// +// Block layout: +// high 256 bits: input bytes +// low 256 bits: 0x80 || zero padding || 0x000...000100 +// +// 0x0100 = 256 bits = 32 bytes * 8. +fn sha256_u256(bytes: u256) -> u256 { + jet::sha_256_block( + jet::sha_256_iv(), + bytes, + 0x8000000000000000000000000000000000000000000000000000000000000100 + ) +} + +// Select one byte from two constants. +// +// Kept as a branch/select helper instead of arithmetic hex conversion. The +// arithmetic form is shorter source-wise, but adds arithmetic/logic jets per +// nibble and is not obviously cheaper. +fn select_u8(bit: u1, if_false: u8, if_true: u8) -> u8 { + match ::into(bit) { + false => if_false, + true => if_true, + } +} + +// Convert a 4-bit nibble into lowercase ASCII hex. +// +// Input bits: +// b3 b2 b1 b0 +// +// Output: +// 0..9 -> '0'..'9' +// 10..15 -> 'a'..'f' +fn hex_nibble(nibble: u4) -> u8 { + let ((b3, b2), (b1, b0)): ((u1, u1), (u1, u1)) = ::into(nibble); + + match ::into(b3) { + false => match ::into(b2) { + false => match ::into(b1) { + false => select_u8(b0, 0x30, 0x31), // 0, 1 + true => select_u8(b0, 0x32, 0x33), // 2, 3 + }, + true => match ::into(b1) { + false => select_u8(b0, 0x34, 0x35), // 4, 5 + true => select_u8(b0, 0x36, 0x37), // 6, 7 + }, + }, + true => match ::into(b2) { + false => match ::into(b1) { + false => select_u8(b0, 0x38, 0x39), // 8, 9 + true => select_u8(b0, 0x61, 0x62), // a, b + }, + true => match ::into(b1) { + false => select_u8(b0, 0x63, 0x64), // c, d + true => select_u8(b0, 0x65, 0x66), // e, f + }, + }, + } +} + +// First SHA256 of the Bitcoin signed-message payload. +// +// Message being hashed: +// 0x18 || "Bitcoin Signed Message:\n" || 0x40 || lowercase_hex(sig_all_hash) +// +// Sizes: +// 0x18 prefix length byte = 1 byte +// "Bitcoin Signed Message:\n" = 24 bytes +// 0x40 message length byte for 64 hex chars = 1 byte +// lowercase hex of 32-byte sighash = 64 bytes +// = 90 bytes total +// +// SHA256 padding length: +// 90 * 8 = 720 = 0x02d0 bits +fn bitcoin_message_hash(sighash: u256) -> u256 { + // SPLIT: 256-bit sighash -> 64 nibbles, big-endian hex order. + let nibbles: [u4; 64] = ::into(sighash); + + let [ + n0, n1, n2, n3, n4, n5, n6, n7, + n8, n9, n10, n11, n12, n13, n14, n15, + n16, n17, n18, n19, n20, n21, n22, n23, + n24, n25, n26, n27, n28, n29, n30, n31, + n32, n33, n34, n35, n36, n37, n38, n39, + n40, n41, n42, n43, n44, n45, n46, n47, + n48, n49, n50, n51, n52, n53, n54, n55, + n56, n57, n58, n59, n60, n61, n62, n63 + ]: [u4; 64] = nibbles; + + // BLOCK 0, high half: + // 0x18 + // "Bitcoin Signed Message:\n" + // 0x40 + // first 6 hex chars + let block0_hi: u256 = <[u8; 32]>::into([ + 0x18, 0x42, 0x69, 0x74, 0x63, 0x6f, 0x69, 0x6e, + 0x20, 0x53, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x20, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x3a, + 0x0a, 0x40, + hex_nibble(n0), hex_nibble(n1), hex_nibble(n2), + hex_nibble(n3), hex_nibble(n4), hex_nibble(n5) + ]); + + // BLOCK 0, low half: + // next 32 hex chars + // + // Total block 0 payload: + // 26 fixed prefix/length bytes + 38 hex bytes = 64 bytes. + let block0_lo: u256 = <[u8; 32]>::into([ + hex_nibble(n6), hex_nibble(n7), hex_nibble(n8), hex_nibble(n9), + hex_nibble(n10), hex_nibble(n11), hex_nibble(n12), hex_nibble(n13), + hex_nibble(n14), hex_nibble(n15), hex_nibble(n16), hex_nibble(n17), + hex_nibble(n18), hex_nibble(n19), hex_nibble(n20), hex_nibble(n21), + hex_nibble(n22), hex_nibble(n23), hex_nibble(n24), hex_nibble(n25), + hex_nibble(n26), hex_nibble(n27), hex_nibble(n28), hex_nibble(n29), + hex_nibble(n30), hex_nibble(n31), hex_nibble(n32), hex_nibble(n33), + hex_nibble(n34), hex_nibble(n35), hex_nibble(n36), hex_nibble(n37) + ]); + + // COMPRESS: first 512-bit SHA block. + let ctx: u256 = jet::sha_256_block(jet::sha_256_iv(), block0_hi, block0_lo); + + // BLOCK 1, high half: + // remaining 26 hex chars + // 0x80 padding byte + // 5 zero bytes + let block1_hi: u256 = <[u8; 32]>::into([ + hex_nibble(n38), hex_nibble(n39), hex_nibble(n40), hex_nibble(n41), + hex_nibble(n42), hex_nibble(n43), hex_nibble(n44), hex_nibble(n45), + hex_nibble(n46), hex_nibble(n47), hex_nibble(n48), hex_nibble(n49), + hex_nibble(n50), hex_nibble(n51), hex_nibble(n52), hex_nibble(n53), + hex_nibble(n54), hex_nibble(n55), hex_nibble(n56), hex_nibble(n57), + hex_nibble(n58), hex_nibble(n59), hex_nibble(n60), hex_nibble(n61), + hex_nibble(n62), hex_nibble(n63), + 0x80, 0, 0, 0, 0, 0 + ]); + + // BLOCK 1, low half: + // zero padding + // 64-bit big-endian message bit length = 0x00000000000002d0 + jet::sha_256_block( + ctx, + block1_hi, + 0x00000000000000000000000000000000000000000000000000000000000002d0 + ) +} + +// Bitcoin signed-message hash: +// +// SHA256(SHA256( +// 0x18 || "Bitcoin Signed Message:\n" || 0x40 || hex(sig_all_hash) +// )) +// +// The final SHA256 input is exactly 32 bytes, so sha256_u256 does one padded +// compression block. +fn message_hash() -> Scalar { + sha256_u256(bitcoin_message_hash(jet::sig_all_hash())) +} + +// ECDSA verification with supplied nonce point. +// +// ECDSA equation: +// s * R = z * G + r * Q +// +// Rearranged for point_verify_1: +// R = (r / s) * Q + (z / s) * G +// +// Required checks: +// 1. r is canonical and nonzero; +// 2. s is canonical and nonzero; +// 3. supplied R has x(R) mod n == r; +// 4. supplied R equals the ECDSA linear combination. +// +// point_verify_1 also decompresses/checks the supplied points. +fn bitcoin_message_ecdsa_verify( + message_hash: Scalar, + pubkey: Point, + nonce: Point, + r: Scalar, + s: Scalar +) { + // CHECK: ECDSA r in [1, n - 1]. + let r_checked: Scalar = checked_ecdsa_scalar(r); + + // CHECK: ECDSA s in [1, n - 1]. + let s_checked: Scalar = checked_ecdsa_scalar(s); + + // CHECK: witness nonce point is actually bound to r. + ensure_nonce_x_matches_r(nonce, r_checked); + + // INV: inv_s = s^-1 mod n. + let inv_s: Scalar = jet::scalar_invert(s_checked); + + // MUL: u1 = z / s mod n. + let z_by_s: Scalar = jet::scalar_multiply(message_hash, inv_s); + + // MUL: u2 = r / s mod n. + let r_by_s: Scalar = jet::scalar_multiply(r_checked, inv_s); + + // VERIFY: + // nonce == r_by_s * pubkey + z_by_s * G + jet::point_verify_1(((r_by_s, pubkey), z_by_s), nonce); +} + +fn main() { + bitcoin_message_ecdsa_verify( + message_hash(), + param::PUBLIC_KEY, + witness::NONCE_POINT, + witness::R, + witness::S + ) +} diff --git a/crates/contracts/src/programs/mod.rs b/crates/contracts/src/programs/mod.rs index 5417bf7..60b43b4 100644 --- a/crates/contracts/src/programs/mod.rs +++ b/crates/contracts/src/programs/mod.rs @@ -1,3 +1,4 @@ +pub mod bitcoin_message_ecdsa_verify; pub mod option_offer; pub mod options; pub mod program; diff --git a/crates/contracts/tests/regtest/bitcoin_message_ecdsa_verify.rs b/crates/contracts/tests/regtest/bitcoin_message_ecdsa_verify.rs new file mode 100644 index 0000000..02ed663 --- /dev/null +++ b/crates/contracts/tests/regtest/bitcoin_message_ecdsa_verify.rs @@ -0,0 +1,248 @@ +#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)] + +use crate::common::signer::{ensure_exact_signer_utxo, finalize_and_broadcast}; + +use contracts::programs::bitcoin_message_ecdsa_verify::{ + BitcoinMessageEcdsaVerify, BitcoinMessageEcdsaVerifyWitness, Scalar, +}; + +use simplex::simplicityhl::elements::bitcoin::consensus::{Encodable, encode::VarInt}; +use simplex::simplicityhl::elements::bitcoin::secp256k1::{ + Message, PublicKey, Secp256k1, SecretKey, +}; +use simplex::simplicityhl::elements::bitcoin::sign_message::BITCOIN_SIGNED_MSG_PREFIX; +use simplex::simplicityhl::elements::confidential::Value; +use simplex::simplicityhl::elements::hashes::{Hash, HashEngine, sha256d}; +use simplex::simplicityhl::elements::hex::ToHex; +use simplex::simplicityhl::elements::pset::{Input, Output, PartiallySignedTransaction}; +use simplex::simplicityhl::elements::{Script, Transaction, TxOut}; +use simplex::simplicityhl::tracker::TrackerLogLevel; +use simplex::transaction::{ + FinalTransaction, PartialInput, PartialOutput, RequiredSignature, UTXO, +}; + +const GROUP_ORDER: Scalar = [ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, + 0xba, 0xae, 0xdc, 0xe6, 0xaf, 0x48, 0xa0, 0x3b, 0xbf, 0xd2, 0x5e, 0x8c, 0xd0, 0x36, 0x41, 0x41, +]; + +const FIELD_PRIME: Scalar = [ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0xff, 0xff, 0xfc, 0x2f, +]; + +struct PreparedCase { + contract: BitcoinMessageEcdsaVerify, + contract_utxo: UTXO, + tx: Transaction, + utxos: Vec, + r: Scalar, + s: Scalar, +} + +#[simplex::test] +fn bitcoin_message_ecdsa_verify_manual_cases(context: simplex::TestContext) -> anyhow::Result<()> { + let prepared = prepare_case(&context)?; + let provider = context.get_default_provider(); + + let finalized_tx = finalize_valid_signature(&prepared)?; + assert_eq!(finalized_tx.input[0].witness.script_witness.len(), 4); + let spend_receipt = provider.broadcast_transaction(&finalized_tx)?; + spend_receipt.wait()?; + let remaining_contract_utxos = + provider.fetch_scripthash_utxos(&prepared.contract.get_script_pubkey())?; + assert!( + remaining_contract_utxos + .iter() + .all(|utxo| utxo.outpoint != prepared.contract_utxo.outpoint), + "contract UTXO was not spent on regtest" + ); + + let mut tampered = prepared.tx.clone(); + tampered.output[0].value = Value::Explicit(2_000); + assert_rejects_for_both_nonce_parities( + &prepared.contract, + &prepared.utxos, + &tampered, + |nonce_y| BitcoinMessageEcdsaVerify::get_witness(nonce_y, prepared.r, prepared.s), + ); + + let mut wrong_nonce_x = prepared.r; + wrong_nonce_x[31] ^= 1; + assert_rejects_for_both_nonce_parities( + &prepared.contract, + &prepared.utxos, + &prepared.tx, + |nonce_y| witness_with_nonce_x(nonce_y, wrong_nonce_x, prepared.r, prepared.s), + ); + + assert_rejects_for_both_nonce_parities( + &prepared.contract, + &prepared.utxos, + &prepared.tx, + |nonce_y| BitcoinMessageEcdsaVerify::get_witness(nonce_y, [0; 32], prepared.s), + ); + + assert_rejects_for_both_nonce_parities( + &prepared.contract, + &prepared.utxos, + &prepared.tx, + |nonce_y| BitcoinMessageEcdsaVerify::get_witness(nonce_y, prepared.r, [0; 32]), + ); + + assert_rejects_for_both_nonce_parities( + &prepared.contract, + &prepared.utxos, + &prepared.tx, + |nonce_y| BitcoinMessageEcdsaVerify::get_witness(nonce_y, GROUP_ORDER, prepared.s), + ); + + assert_rejects_for_both_nonce_parities( + &prepared.contract, + &prepared.utxos, + &prepared.tx, + |nonce_y| BitcoinMessageEcdsaVerify::get_witness(nonce_y, prepared.r, GROUP_ORDER), + ); + + assert_rejects_for_both_nonce_parities( + &prepared.contract, + &prepared.utxos, + &prepared.tx, + |nonce_y| witness_with_nonce_x(nonce_y, FIELD_PRIME, prepared.r, prepared.s), + ); + + Ok(()) +} + +fn prepare_case(context: &simplex::TestContext) -> anyhow::Result { + let network = *context.get_network(); + let provider = context.get_default_provider(); + let signer = context.get_default_signer(); + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[3; 32])?; + let public_key = PublicKey::from_secret_key(&secp, &secret_key); + let contract = BitcoinMessageEcdsaVerify::from_public_key(&public_key, network)?; + let policy_asset = network.policy_asset(); + let contract_amount = 10_000; + let spend_fee = 1_000; + let funding_input_amount = 20_000; + + let funding_utxo = ensure_exact_signer_utxo(context, policy_asset, funding_input_amount)?; + let mut funding = FinalTransaction::new(); + funding.add_input( + PartialInput::new(funding_utxo), + RequiredSignature::NativeEcdsa, + ); + funding.add_output(PartialOutput::new( + contract.get_script_pubkey(), + contract_amount, + policy_asset, + )); + let funding_txid = finalize_and_broadcast(context, &funding)?; + let contract_utxo = provider + .fetch_scripthash_utxos(&contract.get_script_pubkey())? + .into_iter() + .find(|utxo| utxo.outpoint.txid == funding_txid && utxo.outpoint.vout == 0) + .ok_or_else(|| anyhow::anyhow!("missing funded bitcoin-message ECDSA contract UTXO"))?; + + let mut pst = PartiallySignedTransaction::new_v2(); + let mut input = Input::from_prevout(contract_utxo.outpoint); + input.witness_utxo = Some(contract_utxo.txout.clone()); + input.amount = Some(contract_amount); + input.asset = Some(policy_asset); + pst.add_input(input); + pst.add_output(Output::new_explicit( + signer.get_address().script_pubkey(), + contract_amount - spend_fee, + policy_asset, + None, + )); + pst.add_output(Output::new_explicit( + Script::new(), + spend_fee, + policy_asset, + None, + )); + + let tx = pst.extract_tx()?; + let utxos = vec![contract_utxo.txout.clone()]; + let sighash_all = contract.sighash_all(&tx, &utxos, 0)?; + let signed_message_hash = signed_msg_hash_bytes(sighash_all.to_hex().as_bytes()); + let message = Message::from_digest(signed_message_hash.to_byte_array()); + let signature = secp.sign_ecdsa(&message, &secret_key); + secp.verify_ecdsa(&message, &signature, &public_key)?; + + let compact_signature = signature.serialize_compact(); + let r = compact_signature[..32].try_into()?; + let s = compact_signature[32..].try_into()?; + + Ok(PreparedCase { + contract, + contract_utxo, + tx, + utxos, + r, + s, + }) +} + +fn finalize_valid_signature(prepared: &PreparedCase) -> anyhow::Result { + [false, true] + .into_iter() + .find_map(|nonce_y| { + finalize_with_witness( + &prepared.contract, + &prepared.utxos, + &prepared.tx, + BitcoinMessageEcdsaVerify::get_witness(nonce_y, prepared.r, prepared.s), + ) + .ok() + }) + .ok_or_else(|| anyhow::anyhow!("valid ECDSA signature failed for both nonce parities")) +} + +fn assert_rejects_for_both_nonce_parities( + contract: &BitcoinMessageEcdsaVerify, + utxos: &[TxOut], + tx: &Transaction, + witness: impl Fn(bool) -> BitcoinMessageEcdsaVerifyWitness, +) { + for nonce_y in [false, true] { + assert!( + finalize_with_witness(contract, utxos, tx, witness(nonce_y)).is_err(), + "invalid witness unexpectedly succeeded with nonce_y={nonce_y}" + ); + } +} + +fn finalize_with_witness( + contract: &BitcoinMessageEcdsaVerify, + utxos: &[TxOut], + tx: &Transaction, + witness: BitcoinMessageEcdsaVerifyWitness, +) -> anyhow::Result { + Ok(contract.finalize_transaction(tx.clone(), utxos, 0, &witness, TrackerLogLevel::None)?) +} + +const fn witness_with_nonce_x( + nonce_y_is_odd: bool, + nonce_x: Scalar, + r: Scalar, + s: Scalar, +) -> BitcoinMessageEcdsaVerifyWitness { + BitcoinMessageEcdsaVerifyWitness { + nonce_point: (nonce_y_is_odd, nonce_x), + r, + s, + } +} + +fn signed_msg_hash_bytes(message: &[u8]) -> sha256d::Hash { + let mut engine = sha256d::Hash::engine(); + engine.input(BITCOIN_SIGNED_MSG_PREFIX); + VarInt::from(message.len()) + .consensus_encode(&mut engine) + .unwrap(); + engine.input(message); + sha256d::Hash::from_engine(engine) +} diff --git a/crates/contracts/tests/regtest/mod.rs b/crates/contracts/tests/regtest/mod.rs index 535f9c3..8d566c3 100644 --- a/crates/contracts/tests/regtest/mod.rs +++ b/crates/contracts/tests/regtest/mod.rs @@ -1,2 +1,3 @@ +pub mod bitcoin_message_ecdsa_verify; pub mod option_offer; pub mod options;