diff --git a/src/actions/simplicity/pset/mod.rs b/src/actions/simplicity/pset/mod.rs index 62286bc..0d23aa3 100644 --- a/src/actions/simplicity/pset/mod.rs +++ b/src/actions/simplicity/pset/mod.rs @@ -21,6 +21,7 @@ use elements::taproot::ControlBlock; use elements::Script; use serde::Serialize; +use crate::hal_simplicity::control_block_commits_to; use crate::simplicity::jet::elements::{ElementsEnv, ElementsUtxo}; use crate::simplicity::Cmr; @@ -45,6 +46,12 @@ pub enum PsetError { #[error("witness_utxo field not populated for input {0}")] MissingWitnessUtxo(usize), + + #[error("control block for CMR {cmr} does not match input scriptPubKey {script_pubkey}")] + ControlBlockMismatch { + cmr: String, + script_pubkey: String, + }, } #[derive(Serialize)] @@ -111,6 +118,14 @@ pub fn execution_environment( }) .collect::, _>>()?; + let script_pubkey = &input_utxos[input_idx].script_pubkey; + if !control_block_commits_to(&control_block, cmr, script_pubkey) { + return Err(PsetError::ControlBlockMismatch { + cmr: cmr.to_string(), + script_pubkey: script_pubkey.to_string(), + }); + } + let tx_env = ElementsEnv::new( tx, input_utxos, @@ -123,3 +138,40 @@ pub fn execution_environment( Ok((tx_env, control_block, tap_leaf)) } + +#[cfg(test)] +mod tests { + use super::*; + + use elements::{LockTime, Transaction, TxIn, TxOut}; + + use crate::hal_simplicity::script_ver; + use crate::test_helpers::test_leaf; + + #[test] + fn control_block_must_match_witness_utxo() { + let cmr = Cmr::from_byte_array([0; 32]); + let (control_block, script_pubkey) = test_leaf(cmr); + let (_, other_script_pubkey) = test_leaf(Cmr::from_byte_array([1; 32])); + + let pset_spending = |script_pubkey: Script| { + let mut pset = PartiallySignedTransaction::from_tx(Transaction { + version: 2, + lock_time: LockTime::ZERO, + input: vec![TxIn::default()], + output: vec![], + }); + let input = &mut pset.inputs_mut()[0]; + input.witness_utxo = Some(TxOut { + script_pubkey, + ..TxOut::default() + }); + input.tap_scripts.insert(control_block.clone(), script_ver(cmr)); + pset + }; + + assert!(execution_environment(&pset_spending(script_pubkey), 0, cmr, None).is_ok()); + let err = execution_environment(&pset_spending(other_script_pubkey), 0, cmr, None).err(); + assert!(matches!(err, Some(PsetError::ControlBlockMismatch { .. })), "{:?}", err); + } +} diff --git a/src/actions/simplicity/sighash.rs b/src/actions/simplicity/sighash.rs index 6ac9640..b6c941d 100644 --- a/src/actions/simplicity/sighash.rs +++ b/src/actions/simplicity/sighash.rs @@ -17,6 +17,7 @@ use crate::simplicity::elements::taproot::ControlBlock; use crate::simplicity::jet::elements::ElementsEnv; use crate::actions::simplicity::ParseElementsUtxoError; +use crate::hal_simplicity::control_block_commits_to; #[derive(Debug, thiserror::Error)] pub enum SimplicitySighashError { @@ -41,7 +42,7 @@ pub enum SimplicitySighashError { #[error("invalid control block decoding: {0}")] ControlBlockDecoding(elements::taproot::TaprootError), - #[error("input index {index} out-of-range for PSET with {n_inputs} inputs")] + #[error("input index {index} out-of-range for transaction with {n_inputs} inputs")] InputIndexOutOfRange { index: u32, n_inputs: usize, @@ -55,6 +56,12 @@ pub enum SimplicitySighashError { #[error("with a raw transaction, control-block must be provided")] ControlBlockRequired, + #[error("control block for CMR {cmr} does not match input scriptPubKey {script_pubkey}")] + ControlBlockMismatch { + cmr: String, + script_pubkey: String, + }, + #[error("witness UTXO field not populated for input {input}")] WitnessUtxoMissing { input: usize, @@ -134,6 +141,13 @@ pub fn simplicity_sighash( } }; let input_idx: u32 = input_idx.parse().map_err(SimplicitySighashError::InputIndexParsing)?; + // Checked here because the PSET input lookup below is skipped when a control block is given. + if input_idx as usize >= tx.input.len() { + return Err(SimplicitySighashError::InputIndexOutOfRange { + index: input_idx, + n_inputs: tx.input.len(), + }); + } let cmr: Cmr = cmr.parse().map_err(SimplicitySighashError::CmrParsing)?; // If the user specifies a control block, use it. Otherwise query the PSET. @@ -143,14 +157,7 @@ pub fn simplicity_sighash( // value f5919fa64ce45f8306849072b26c1bfdd2937e6b81774796ff372bd1eb5362d2 ControlBlock::from_slice(&cb_bytes).map_err(SimplicitySighashError::ControlBlockDecoding)? } else if let Some(ref pset) = pset { - let n_inputs = pset.n_inputs(); - let input = pset - .inputs() - .get(input_idx as usize) // cast u32->usize probably fine - .ok_or(SimplicitySighashError::InputIndexOutOfRange { - index: input_idx, - n_inputs, - })?; + let input = &pset.inputs()[input_idx as usize]; // bounds-checked above let mut control_block = None; for (cb, script_ver) in &input.tap_scripts { @@ -203,6 +210,14 @@ pub fn simplicity_sighash( }); } + let script_pubkey = &input_utxos[input_idx as usize].script_pubkey; + if !control_block_commits_to(&control_block, cmr, script_pubkey) { + return Err(SimplicitySighashError::ControlBlockMismatch { + cmr: cmr.to_string(), + script_pubkey: script_pubkey.to_string(), + }); + } + // Default to Bitcoin blockhash. let genesis_hash = match genesis_hash { Some(s) => s.parse().map_err(SimplicitySighashError::GenesisHashParsing)?, @@ -268,3 +283,78 @@ pub fn simplicity_sighash( }, }) } + +#[cfg(test)] +mod tests { + use super::*; + + use elements::encode::serialize_hex; + use elements::{LockTime, Script, Transaction, TxIn}; + + use crate::test_helpers::test_leaf; + + const CMR: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + + fn two_input_tx() -> Transaction { + Transaction { + version: 2, + lock_time: LockTime::ZERO, + input: vec![TxIn::default(), TxIn::default()], + output: vec![], + } + } + + /// Sighash using an explicit control block for `CMR`, with both input UTXOs + /// locked to `script_pubkey`. + fn sighash( + tx: &str, + idx: &str, + script_pubkey: &Script, + ) -> Result { + let (control_block, _) = test_leaf(CMR.parse().unwrap()); + let utxo = format!("{}:{}:0.0001", hex::encode(script_pubkey.as_bytes()), "00".repeat(32)); + simplicity_sighash( + tx, + idx, + CMR, + Some(&hex::encode(control_block.serialize())), + None, + None, + None, + None, + Some(&[&utxo, &utxo]), + ) + } + + #[test] + fn input_index_out_of_range_with_explicit_control_block() { + let (_, script_pubkey) = test_leaf(CMR.parse().unwrap()); + let tx = two_input_tx(); + for tx in [serialize_hex(&tx), PartiallySignedTransaction::from_tx(tx).to_string()] { + assert!(sighash(&tx, "1", &script_pubkey).is_ok()); + let err = sighash(&tx, "2", &script_pubkey).err(); + assert!( + matches!( + err, + Some(SimplicitySighashError::InputIndexOutOfRange { + index: 2, + n_inputs: 2 + }) + ), + "{:?}", + err + ); + } + } + + #[test] + fn control_block_must_match_input_utxo() { + let (_, other_script_pubkey) = test_leaf(Cmr::from_byte_array([1; 32])); + let err = sighash(&serialize_hex(&two_input_tx()), "0", &other_script_pubkey).err(); + assert!( + matches!(err, Some(SimplicitySighashError::ControlBlockMismatch { .. })), + "{:?}", + err + ); + } +} diff --git a/src/hal_simplicity.rs b/src/hal_simplicity.rs index 13d2e6e..de190e5 100644 --- a/src/hal_simplicity.rs +++ b/src/hal_simplicity.rs @@ -3,7 +3,8 @@ use std::sync::Arc; -use elements::taproot::{TaprootBuilder, TaprootSpendInfo}; +use elements::schnorr::TweakedPublicKey; +use elements::taproot::{ControlBlock, TaprootBuilder, TaprootSpendInfo}; use simplicity::bitcoin::secp256k1; use simplicity::jet::Jet; use simplicity::{BitIter, CommitNode, DecodeError, ParseError, RedeemNode}; @@ -103,7 +104,9 @@ pub fn unspendable_internal_key() -> secp256k1::XOnlyPublicKey { .expect("key should be valid") } -fn script_ver(cmr: simplicity::Cmr) -> (elements::Script, elements::taproot::LeafVersion) { +pub(crate) fn script_ver( + cmr: simplicity::Cmr, +) -> (elements::Script, elements::taproot::LeafVersion) { let script = elements::script::Script::from(cmr.as_ref().to_vec()); (script, simplicity::leaf_version()) } @@ -138,6 +141,27 @@ pub fn taproot_spend_info( builder.finalize(secp256k1::SECP256K1, internal_key).expect("tap tree should be valid") } +/// Whether `control_block` proves that the Simplicity leaf `cmr` is committed to +/// by `script_pubkey`, for any Taptree shape. +pub(crate) fn control_block_commits_to( + control_block: &ControlBlock, + cmr: simplicity::Cmr, + script_pubkey: &elements::Script, +) -> bool { + let (script, version) = script_ver(cmr); + if control_block.leaf_version != version || !script_pubkey.is_v1_p2tr() { + return false; + } + match secp256k1::XOnlyPublicKey::from_slice(&script_pubkey[2..]) { + Ok(output_key) => control_block.verify_taproot_commitment( + secp256k1::SECP256K1, + &TweakedPublicKey::new(output_key), + &script, + ), + Err(_) => false, + } +} + /// Given a Simplicity CMR, computes an unconfidential Elements address /// (for the given network) corresponding to a Taptree with an unspendable /// internal key and this CMR as its single leaf. diff --git a/src/lib.rs b/src/lib.rs index 09f4a6d..728aa4a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,9 @@ pub mod tx; pub mod confidential; +#[cfg(test)] +mod test_helpers; + pub use elements::bitcoin; pub use hal::HexBytes; diff --git a/src/test_helpers.rs b/src/test_helpers.rs new file mode 100644 index 0000000..aba0fe7 --- /dev/null +++ b/src/test_helpers.rs @@ -0,0 +1,13 @@ +//! Helpers shared between unit tests in different modules. + +use elements::taproot::ControlBlock; +use elements::Script; + +use crate::hal_simplicity::{script_ver, taproot_spend_info, unspendable_internal_key}; + +/// The control block and scriptPubKey for a Taptree with this CMR as its single leaf. +pub fn test_leaf(cmr: simplicity::Cmr) -> (ControlBlock, Script) { + let spend_info = taproot_spend_info(unspendable_internal_key(), None, cmr); + let control_block = spend_info.control_block(&script_ver(cmr)).expect("leaf is in tree"); + (control_block, Script::new_v1_p2tr_tweaked(spend_info.output_key())) +}