From b32abf596f67f9c95cbb35997f5d0bc844516806 Mon Sep 17 00:00:00 2001 From: Tommy Volk Date: Fri, 31 Jul 2026 14:33:14 -0500 Subject: [PATCH] refactor: centralize market contract execution --- crates/deadcat-client/src/lib.rs | 2 - crates/deadcat-client/src/market_builder.rs | 54 +- crates/deadcat-client/src/simplicity.rs | 31 - crates/deadcat-client/src/validation.rs | 10 +- crates/deadcat-client/tests/market_regtest.rs | 22 +- .../deadcat-client/tests/simplicity_budget.rs | 84 ++- crates/deadcat-contracts/src/binary_market.rs | 5 +- .../src/binary_market/compiled.rs | 529 ++++++++++++++++-- .../deadcat-contracts/src/finalized_spend.rs | 296 ++++++++++ crates/deadcat-contracts/src/interpret.rs | 72 +-- .../src/interpret/binary_market.rs | 17 +- crates/deadcat-contracts/src/lib.rs | 1 + .../tests/covenant_execution.rs | 16 +- crates/deadcat-contracts/tests/interpret.rs | 113 +++- crates/deadcat-node/src/registration.rs | 9 +- 15 files changed, 1001 insertions(+), 260 deletions(-) delete mode 100644 crates/deadcat-client/src/simplicity.rs create mode 100644 crates/deadcat-contracts/src/finalized_spend.rs diff --git a/crates/deadcat-client/src/lib.rs b/crates/deadcat-client/src/lib.rs index 2c7616e..caffcf2 100644 --- a/crates/deadcat-client/src/lib.rs +++ b/crates/deadcat-client/src/lib.rs @@ -2,5 +2,3 @@ pub mod market_builder; pub mod validation; - -mod simplicity; diff --git a/crates/deadcat-client/src/market_builder.rs b/crates/deadcat-client/src/market_builder.rs index 4456014..652b52f 100644 --- a/crates/deadcat-client/src/market_builder.rs +++ b/crates/deadcat-client/src/market_builder.rs @@ -10,8 +10,10 @@ use deadcat_contracts::SimplicityNetwork; use deadcat_contracts::binary_market::{ AppliedBinaryMarketTransition, BinaryMarketAction, BinaryMarketEconomics, BinaryMarketError, BinaryMarketSlot, BinaryMarketTransition, BinaryOutcome, CompiledBinaryMarket, - derived_binary_market, + CompiledBinaryMarketError, CompiledBinaryMarketExecutionError, derived_binary_market, }; +#[cfg(test)] +use deadcat_contracts::finalized_spend::FinalizedSimplicitySpend; use deadcat_contracts::interpret::BinaryMarketPath; use deadcat_contracts::market_crypto::{ BinaryOutcome as OracleOutcome, derive_issuance_assets, oracle_message, @@ -565,12 +567,13 @@ impl BinaryMarketTransitionPlan { output_base: usize, network: &SimplicityNetwork, ) -> Result<(), MarketBuilderError> { - if pset + if let Some((input_index, _)) = pset .inputs() .iter() - .any(|input| input.witness_utxo.is_none()) + .enumerate() + .find(|(_, input)| input.witness_utxo.is_none()) { - return Err(MarketBuilderError::MissingWitnessUtxo); + return Err(MarketBuilderError::MissingWitnessUtxo { input_index }); } self.verify_inputs(compiled, pset, input_base)?; self.verify_expiry(pset, input_base)?; @@ -654,16 +657,17 @@ impl BinaryMarketTransitionPlan { tokens_burned: self.tokens_burned, redeem_yes: self.redeem_yes, }; - let stack = compiled - .finalize(slot, pset, &witness.build_witness(), input_index, network) - .map_err(|error| MarketBuilderError::Covenant(error.to_string()))?; - let stack = - crate::simplicity::ensure_budget(stack).map_err(MarketBuilderError::Covenant)?; - finalized.push((input_index, stack)); + let spend = + compiled.finalize(slot, pset, &witness.build_witness(), input_index, network)?; + finalized.push((input_index, spend.into_witness_stack())); } for (input_index, stack) in finalized { pset.inputs_mut()[input_index].final_script_witness = Some(stack); } + for (offset, slot) in input_slots.iter().copied().enumerate() { + let input_index = add_index(input_base, offset)?; + compiled.execute_finalized(slot, pset, input_index, network)?; + } Ok(()) } @@ -706,7 +710,7 @@ impl BinaryMarketTransitionPlan { let utxo = input .witness_utxo .as_ref() - .ok_or(MarketBuilderError::MissingWitnessUtxo)?; + .ok_or(MarketBuilderError::MissingWitnessUtxo { input_index: index })?; if utxo.script_pubkey != *compiled.slot(slot).script_pubkey() { return Err(MarketBuilderError::WrongContractInput); } @@ -1460,8 +1464,7 @@ fn add_index(base: usize, offset: usize) -> Result { } fn compile(params: BinaryMarketParams) -> Result { - CompiledBinaryMarket::new(params) - .map_err(|error| MarketBuilderError::Compilation(error.to_string())) + Ok(CompiledBinaryMarket::new(params)?) } #[derive(Debug, Error)] @@ -1473,7 +1476,7 @@ pub enum MarketBuilderError { #[error("RT commitment error: {0}")] RtCommitment(#[from] RtCommitmentError), #[error("contract compilation failed: {0}")] - Compilation(String), + Compilation(#[from] CompiledBinaryMarketError), #[error("compiled binary-market parameters do not match the transition plan")] CompiledParamsMismatch, #[error("market recovery hint disagrees with the supplied parameters")] @@ -1524,8 +1527,8 @@ pub enum MarketBuilderError { InputIndexOutOfBounds, #[error("PSET output index is out of bounds")] OutputIndexOutOfBounds, - #[error("PSET is missing witness_utxo evidence")] - MissingWitnessUtxo, + #[error("PSET input {input_index} is missing witness_utxo evidence")] + MissingWitnessUtxo { input_index: usize }, #[error("PSET contract input does not match the plan")] WrongContractInput, #[error("PSET reissuance fields do not match the plan")] @@ -1543,7 +1546,7 @@ pub enum MarketBuilderError { #[error("mandatory covenant output at index {index} does not match the plan")] MandatoryOutputMismatch { index: usize }, #[error("Simplicity covenant finalization failed: {0}")] - Covenant(String), + Covenant(#[from] CompiledBinaryMarketExecutionError), } #[cfg(test)] @@ -2004,10 +2007,12 @@ mod tests { .final_script_witness .as_ref() .expect("final witness"); - let (core, annex) = deadcat_contracts::interpret::strip_taproot_annex(stack); - assert_eq!(core.len(), 4); + let finalized = FinalizedSimplicitySpend::parse_witness_stack(stack) + .expect("typed finalized Simplicity witness"); assert!( - annex.is_none_or(|padding| padding.first() == Some(&0x50)), + finalized + .annex() + .is_none_or(|padding| padding.first() == Some(&0x50)), "budget padding must be a Taproot annex" ); } @@ -2072,6 +2077,15 @@ mod tests { policy_asset: params.collateral_asset_id, }; let mut pset = pset_for_plan(&expiry, 0, 0); + let mut missing_utxo = pset.clone(); + missing_utxo.inputs_mut()[1].witness_utxo = None; + let missing_untouched = missing_utxo.clone(); + assert!(matches!( + expiry.finalize(&mut missing_utxo, 0, 0, &network), + Err(MarketBuilderError::MissingWitnessUtxo { input_index: 1 }) + )); + assert_eq!(missing_utxo, missing_untouched); + let untouched = pset.clone(); assert!(matches!( expiry.finalize(&mut pset, 0, 0, &network), diff --git a/crates/deadcat-client/src/simplicity.rs b/crates/deadcat-client/src/simplicity.rs deleted file mode 100644 index 383bb8f..0000000 --- a/crates/deadcat-client/src/simplicity.rs +++ /dev/null @@ -1,31 +0,0 @@ -use simplex::simplicityhl::simplicity::jet::Elements; -use simplex::simplicityhl::simplicity::{BitIter, RedeemNode}; - -pub(crate) fn ensure_budget(mut stack: Vec>) -> Result>, String> { - if stack.len() != 4 { - return Err(format!( - "expected four finalized Simplicity stack elements, got {}", - stack.len() - )); - } - let redeem = RedeemNode::decode::<_, _, Elements>( - BitIter::from(stack[1].iter().copied()), - BitIter::from(stack[0].iter().copied()), - ) - .map_err(|error| format!("failed to decode finalized Simplicity program: {error:?}"))?; - let cost = redeem.bounds().cost; - if !cost.is_budget_valid(&stack) { - let padding = cost.get_padding(&stack).ok_or_else(|| { - format!( - "Simplicity stack is underbudget for execution cost {cost} and cannot be padded" - ) - })?; - stack.push(padding); - } - if !cost.is_budget_valid(&stack) { - return Err(format!( - "Simplicity stack remains underbudget for execution cost {cost} after padding" - )); - } - Ok(stack) -} diff --git a/crates/deadcat-client/src/validation.rs b/crates/deadcat-client/src/validation.rs index 7c9aa6b..53eaf57 100644 --- a/crates/deadcat-client/src/validation.rs +++ b/crates/deadcat-client/src/validation.rs @@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet}; use deadcat_contracts::SimplicityNetwork; use deadcat_contracts::binary_market::{ BinaryMarketEconomics, BinaryMarketSlot, BinaryMarketTransition, BinaryOutcome, - CompiledBinaryMarket, + CompiledBinaryMarket, CompiledBinaryMarketError, }; use deadcat_contracts::interpret::{ BinaryMarketLiveOutputs, BinaryMarketPath, TrackedContractOutput, interpret_binary_market_spend, @@ -92,8 +92,7 @@ pub fn validate_contract_view( let ContractParametersView::BinaryMarket { params } = &view.parameters; let ContractStateView::BinaryMarket { state } = view.state; - CompiledBinaryMarket::new(*params) - .map_err(|error| ValidationError::Compilation(error.to_string()))?; + CompiledBinaryMarket::new(*params)?; BinaryMarketEconomics::new(params.base_payout) .and_then(|economics| economics.validate_state(state)) .map_err(|error| ValidationError::Economics(error.to_string()))?; @@ -223,8 +222,7 @@ fn replay_market( creation: &TransactionEvidence, transitions: &[TransactionEvidence], ) -> Result<(), ValidationError> { - let compiled = CompiledBinaryMarket::new(params) - .map_err(|error| ValidationError::Compilation(error.to_string()))?; + let compiled = CompiledBinaryMarket::new(params)?; let yes_input = unique_defining_input( &creation.transaction, params.yes_token_asset_id, @@ -675,7 +673,7 @@ pub enum ValidationError { #[error("invalid contract shape: {0}")] ContractShape(&'static str), #[error("contract compilation failed: {0}")] - Compilation(String), + Compilation(#[from] CompiledBinaryMarketError), #[error("invalid contract economics: {0}")] Economics(String), #[error("duplicate live role or invalid live-output shape: {0}")] diff --git a/crates/deadcat-client/tests/market_regtest.rs b/crates/deadcat-client/tests/market_regtest.rs index ccb30c6..af78ea8 100644 --- a/crates/deadcat-client/tests/market_regtest.rs +++ b/crates/deadcat-client/tests/market_regtest.rs @@ -62,7 +62,7 @@ use elements::{ }; use serde::{Deserialize, Serialize}; use serde_json::{Value as JsonValue, json}; -use simplex::program::{ProgramTrait as _, WitnessTrait as _}; +use simplex::program::WitnessTrait as _; use simplex::provider::ElementsRpc; use simplex::signer::{Signer, SignerTrait as _}; use simplex::transaction::{FinalTransaction, PartialOutput}; @@ -1174,21 +1174,11 @@ fn rebuild_pruned_market_followers_from_divergent_witnesses( tokens_burned, redeem_yes, }; - compiled - .program(slot) - .as_ref() - .execute(pset, &witness.build_witness(), input_index, network) - .unwrap_or_else(|error| panic!("divergent {slot:?} follower: {error}")); - let mut rebuilt = compiled - .program(slot) - .as_ref() - .finalize(pset, &witness.build_witness(), input_index, network) - .expect("finalize divergent follower witness"); - match canonical.len() { - 4 => {} - 5 => rebuilt.push(canonical[4].clone()), - length => panic!("unexpected canonical follower stack length {length}"), - } + let rebuilt = compiled + .finalize(slot, pset, &witness.build_witness(), input_index, network) + .unwrap_or_else(|error| panic!("finalize divergent {slot:?} follower: {error}")) + .into_witness_stack(); + assert!((4..=5).contains(&canonical.len())); pset.inputs_mut()[input_index].final_script_witness = Some(rebuilt); } assert_eq!( diff --git a/crates/deadcat-client/tests/simplicity_budget.rs b/crates/deadcat-client/tests/simplicity_budget.rs index d74821c..9639cd9 100644 --- a/crates/deadcat-client/tests/simplicity_budget.rs +++ b/crates/deadcat-client/tests/simplicity_budget.rs @@ -7,7 +7,7 @@ use deadcat_contracts::binary_market::{ BinaryMarketAction, BinaryMarketEconomics, BinaryMarketSlot, BinaryOutcome, CompiledBinaryMarket, derived_binary_market, }; -use deadcat_contracts::interpret::strip_taproot_annex; +use deadcat_contracts::finalized_spend::FinalizedSimplicitySpend; use deadcat_contracts::interpret::{ BinaryMarketLiveOutputs, TrackedContractOutput, interpret_binary_market_spend_with_compiled, }; @@ -22,9 +22,8 @@ use elements::pset::{Input as PsetInput, Output as PsetOutput, PartiallySignedTr use elements::secp256k1_zkp::{Keypair, Message, Secp256k1, Tweak}; use elements::{AssetId, LockTime, OutPoint, Script, Sequence, TxOut, TxOutWitness, Txid}; use serde::Serialize; -use simplex::program::{ProgramTrait as _, WitnessTrait as _}; -use simplex::simplicityhl::simplicity::jet::Elements; -use simplex::simplicityhl::simplicity::{BitIter, Cost, RedeemNode}; +use simplex::program::WitnessTrait as _; +use simplex::simplicityhl::simplicity::Cost; // Rounded CI ceilings with headroom above the reviewed maxima of 4,557,857 mw, // 70,903 cells, 62 frames, 6,223 stack bytes, 15,334 transaction bytes, @@ -149,34 +148,15 @@ fn assert_canonical_padding(label: &str, annex: &[u8]) { fn record_budget(label: impl Into, stack: &[Vec]) -> CovenantMetrics { let label = label.into(); let stack = stack.to_vec(); - let (core_stack, annex) = strip_taproot_annex(&stack); - assert_eq!( - core_stack.len(), - 4, - "{label}: finalized Simplicity stack must have four core elements" - ); - let redeem = RedeemNode::decode::<_, _, Elements>( - BitIter::from(core_stack[1].iter().copied()), - BitIter::from(core_stack[0].iter().copied()), - ) - .expect("decode finalized Simplicity program"); - let bounds = redeem.bounds(); + let finalized = FinalizedSimplicitySpend::from_witness_stack(stack.clone()) + .unwrap_or_else(|error| panic!("{label}: {error}")); + let bounds = finalized.bounds(); let cost = bounds.cost; - assert!( - cost.is_budget_valid(&stack), - "{label}: finalized stack is underbudget for execution cost {cost}mw" - ); - - let canonical_padding = cost.get_padding(&core_stack.to_vec()); - assert_eq!( - annex, - canonical_padding.as_deref(), - "{label}: finalized stack must use exactly the canonical budget padding" - ); + let annex = finalized.annex(); if let Some(annex) = annex { assert_canonical_padding(&label, annex); if annex.len() > 1 { - let mut shortened = stack.clone(); + let mut shortened = stack; assert_eq!( shortened.last_mut().expect("annex").pop(), Some(0), @@ -189,14 +169,15 @@ fn record_budget(label: impl Into, stack: &[Vec]) -> CovenantMetrics } } + let sizes = finalized.encoded_sizes(); CovenantMetrics { cost_milliweight: cost_milliweight(cost), max_extra_cells: bounds.extra_cells, max_extra_frames: bounds.extra_frames, - program_bytes: core_stack[1].len(), - witness_bytes: core_stack[0].len(), - stack_bytes: elements::encode::serialize(&stack).len(), - padding_bytes: annex.map_or(0, <[u8]>::len), + program_bytes: sizes.program_bytes, + witness_bytes: sizes.witness_bytes, + stack_bytes: sizes.stack_bytes, + padding_bytes: sizes.annex_bytes, } } @@ -656,7 +637,7 @@ fn finalized_market_fixture( } #[test] -fn reusable_compiled_market_execution_matches_program_api() { +fn typed_finalized_spend_round_trips_and_reexecutes_from_the_installed_stack() { let params = market_params(); let compiled = CompiledBinaryMarket::new(params).expect("compile canonical market"); let before = BinaryMarketState::Trading { @@ -678,17 +659,29 @@ fn reusable_compiled_market_execution_matches_program_api() { let network = SimplicityNetwork::ElementsRegtest { policy_asset: params.collateral_asset_id, }; + let installed = pset.inputs()[input_base] + .final_script_witness + .as_ref() + .expect("installed finalized witness"); - let reused = compiled + let finalized = compiled .finalize(slot, &pset, &witness.build_witness(), input_base, &network) - .expect("finalize retained compiled program"); - let program_api = compiled - .program(slot) - .as_ref() - .finalize(&pset, &witness.build_witness(), input_base, &network) - .expect("finalize SDK program"); + .expect("build typed finalized spend"); + assert_eq!(finalized.witness_stack(), installed); + assert_eq!(finalized.cmr(), compiled.cmr()); + assert_eq!( + finalized.control_block(), + compiled.slot(slot).control_block() + ); + assert_eq!( + finalized.encoded_sizes().stack_bytes, + elements::encode::serialize(installed).len() + ); + assert_eq!(finalized.into_witness_stack(), *installed); - assert_eq!(reused, program_api); + compiled + .execute_finalized(slot, &pset, input_base, &network) + .expect("re-execute installed finalized witness"); } #[test] @@ -1812,7 +1805,7 @@ fn market_followers_ignore_transition_witnesses_but_require_the_exact_coordinato .as_ref() .expect("canonical follower stack") .clone(); - let mut mixed_stack = compiled + let mixed_stack = compiled .finalize( slot, &mixed_witness_pset, @@ -1820,10 +1813,9 @@ fn market_followers_ignore_transition_witnesses_but_require_the_exact_coordinato input_index, &network, ) - .expect("finalize mixed follower"); - if canonical_stack.len() == 5 { - mixed_stack.push(canonical_stack[4].clone()); - } + .expect("finalize mixed follower") + .into_witness_stack(); + assert!((4..=5).contains(&canonical_stack.len())); record_budget(format!("mixed-follower-{slot:?}"), &mixed_stack); mixed_witness_pset.inputs_mut()[input_index].final_script_witness = Some(mixed_stack); } diff --git a/crates/deadcat-contracts/src/binary_market.rs b/crates/deadcat-contracts/src/binary_market.rs index 00db833..a654205 100644 --- a/crates/deadcat-contracts/src/binary_market.rs +++ b/crates/deadcat-contracts/src/binary_market.rs @@ -13,7 +13,10 @@ mod compiled; pub use crate::artifacts::binary_market::BinaryMarketProgram; pub use crate::artifacts::binary_market::derived_binary_market; -pub use compiled::{CompiledBinaryMarket, CompiledBinaryMarketError, CompiledBinaryMarketSlot}; +pub use compiled::{ + CompiledBinaryMarket, CompiledBinaryMarketError, CompiledBinaryMarketExecutionError, + CompiledBinaryMarketSlot, +}; /// Version byte stored in market slot scripts. pub const BINARY_MARKET_STORAGE_VERSION: u8 = 0x01; diff --git a/crates/deadcat-contracts/src/binary_market/compiled.rs b/crates/deadcat-contracts/src/binary_market/compiled.rs index f7f8dee..46b6e12 100644 --- a/crates/deadcat-contracts/src/binary_market/compiled.rs +++ b/crates/deadcat-contracts/src/binary_market/compiled.rs @@ -1,7 +1,7 @@ //! Validation-first compilation of the canonical binary-market covenant. use std::collections::HashSet; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use elements::confidential::{Asset, Value}; use elements::hashes::{Hash as _, HashEngine as _, sha256}; @@ -14,21 +14,54 @@ use simplex::program::logger::ProgramLogger; use simplex::program::{ArgumentsTrait as _, ProgramError}; use simplex::provider::SimplicityNetwork; use simplex::simplicityhl::ast::ElementsJetHinter; +use simplex::simplicityhl::error::ErrorCollector; use simplex::simplicityhl::simplicity::jet::elements::{ElementsEnv, ElementsUtxo}; use simplex::simplicityhl::simplicity::{ BitMachine, HasCmr as _, RedeemNode, Value as SimplicityValue, leaf_version, }; -use simplex::simplicityhl::{CompiledProgram, UnstableFeature, UnstableFeatures, WitnessValues}; +use simplex::simplicityhl::{ + CompiledProgram, TemplateProgram, UnstableFeature, UnstableFeatures, WitnessValues, +}; use thiserror::Error; use super::{BinaryMarketEconomics, BinaryMarketParams, BinaryMarketSlot}; use crate::artifacts::binary_market::{BinaryMarketProgram, derived_binary_market}; +use crate::finalized_spend::{FinalizedSimplicitySpend, FinalizedSimplicitySpendError}; use crate::rt::{RtCommitmentError, RtLeg, RtSide, commitments, factors}; const NUMS_INTERNAL_KEY: [u8; 32] = [ 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, ]; +const TAPROOT_ANNEX_TAG: u8 = 0x50; + +static BINARY_MARKET_TEMPLATE: LazyLock>> = + LazyLock::new(analyze_binary_market_template); + +#[cfg(test)] +static TEMPLATE_ANALYSIS_COUNT: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +fn analyze_binary_market_template() -> Result> { + #[cfg(test)] + TEMPLATE_ANALYSIS_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + + TemplateProgram::new_with_unstable( + BinaryMarketProgram::SOURCE, + &UnstableFeatures::new([UnstableFeature::Imports]), + Box::new(ElementsJetHinter), + ) + .map_err(Arc::new) +} + +fn binary_market_template() -> Result<&'static TemplateProgram, CompiledBinaryMarketError> { + match &*BINARY_MARKET_TEMPLATE { + Ok(template) => Ok(template), + Err(error) => Err(CompiledBinaryMarketError::TemplateCompilation(Arc::clone( + error, + ))), + } +} /// One fully materialized static slot of a compiled binary market. #[derive(Clone, Debug, PartialEq, Eq)] @@ -69,6 +102,7 @@ impl CompiledBinaryMarketSlot { #[derive(Clone, Debug)] pub struct CompiledBinaryMarket { params: BinaryMarketParams, + #[cfg(test)] arguments: derived_binary_market::BinaryMarketArguments, compiled: CompiledProgram, cmr: [u8; 32], @@ -81,14 +115,9 @@ impl CompiledBinaryMarket { validate_params(params)?; let arguments = contract_arguments(params)?; - let compiled = CompiledProgram::new_with_unstable( - BinaryMarketProgram::SOURCE, - &UnstableFeatures::new([UnstableFeature::Imports]), - arguments.build_arguments(), - false, - Box::new(ElementsJetHinter), - ) - .map_err(CompiledBinaryMarketError::Compilation)?; + let compiled = binary_market_template()? + .instantiate(arguments.build_arguments(), false) + .map_err(CompiledBinaryMarketError::ArgumentInstantiation)?; let cmr_node = compiled.commit().cmr(); let mut cmr = [0_u8; 32]; cmr.copy_from_slice(cmr_node.as_ref()); @@ -112,6 +141,7 @@ impl CompiledBinaryMarket { Ok(Self { params, + #[cfg(test)] arguments, compiled, cmr, @@ -139,13 +169,11 @@ impl CompiledBinaryMarket { &self.slots[slot as usize] } - /// Recreate the generated smplx program at one validated storage slot. - /// - /// This is intended for execution/finalization. Script discovery should use - /// [`Self::slot`], whose value was constructed without panic-based helpers. + /// Recreate the generated smplx program for the sole SDK parity regression. + #[cfg(test)] #[must_use] #[allow(unused_must_use)] - pub fn program(&self, slot: BinaryMarketSlot) -> BinaryMarketProgram { + fn program(&self, slot: BinaryMarketSlot) -> BinaryMarketProgram { let mut program = BinaryMarketProgram::new(self.arguments.clone()).with_storage_capacity(1); program.set_storage_at(0, slot.storage_word()); program @@ -159,7 +187,7 @@ impl CompiledBinaryMarket { witness: &WitnessValues, input_index: usize, network: &SimplicityNetwork, - ) -> Result<(Arc, SimplicityValue), ProgramError> { + ) -> Result<(Arc, SimplicityValue), CompiledBinaryMarketExecutionError> { let satisfied = self .compiled .satisfy(witness.clone()) @@ -169,13 +197,15 @@ impl CompiledBinaryMarket { let environment = self.environment(slot, pset, input_index, network)?; let pruned = satisfied .redeem() - .prune_with_tracker(&environment, &mut tracker)?; + .prune_with_tracker(&environment, &mut tracker) + .map_err(ProgramError::Pruning)?; if GlobalConfig::is_max_verbose() { ProgramLogger::buffer_cost_log(&pruned); } - let mut machine = BitMachine::for_program(&pruned)?; + let mut machine = + BitMachine::for_program(&pruned).map_err(ProgramError::BitMachineCreation)?; let result = machine .exec(&pruned, &environment) .map_err(ProgramError::Execution)?; @@ -190,15 +220,62 @@ impl CompiledBinaryMarket { witness: &WitnessValues, input_index: usize, network: &SimplicityNetwork, - ) -> Result>, ProgramError> { + ) -> Result { let pruned = self.execute(slot, pset, witness, input_index, network)?.0; let (program_bytes, witness_bytes) = pruned.to_vec_with_witness(); - Ok(vec![ + Ok(FinalizedSimplicitySpend::from_core_stack([ witness_bytes, program_bytes, pruned.cmr().as_ref().to_vec(), self.slot(slot).control_block().serialize(), - ]) + ])?) + } + + /// Re-execute the finalized Simplicity witness installed on one PSET input. + pub fn execute_finalized( + &self, + slot: BinaryMarketSlot, + pset: &PartiallySignedTransaction, + input_index: usize, + network: &SimplicityNetwork, + ) -> Result { + let Some(input) = pset.inputs().get(input_index) else { + return Err(ProgramError::UtxoIndexOutOfBounds { + input_index, + utxo_count: pset.inputs().len(), + } + .into()); + }; + let witness_stack = input + .final_script_witness + .as_ref() + .ok_or(CompiledBinaryMarketExecutionError::MissingFinalScriptWitness { input_index })?; + let finalized = FinalizedSimplicitySpend::parse_witness_stack(witness_stack)?; + if finalized.cmr() != self.cmr { + return Err(CompiledBinaryMarketExecutionError::CmrMismatch { + expected: self.cmr, + actual: finalized.cmr(), + }); + } + let expected_control_block = self.slot(slot).control_block(); + if finalized.control_block() != expected_control_block { + return Err(CompiledBinaryMarketExecutionError::ControlBlockMismatch { + expected: Box::new(expected_control_block.clone()), + actual: Box::new(finalized.control_block().clone()), + }); + } + + let environment = self.environment(slot, pset, input_index, network)?; + let redeem_node = finalized.redeem_node(); + if GlobalConfig::is_max_verbose() { + ProgramLogger::buffer_cost_log(redeem_node); + } + let mut machine = + BitMachine::for_program(redeem_node).map_err(ProgramError::BitMachineCreation)?; + machine + .exec(redeem_node, &environment) + .map_err(ProgramError::Execution) + .map_err(Into::into) } fn environment( @@ -207,28 +284,28 @@ impl CompiledBinaryMarket { pset: &PartiallySignedTransaction, input_index: usize, network: &SimplicityNetwork, - ) -> Result>, ProgramError> { - let utxos: Vec = pset - .inputs() - .iter() - .filter_map(|input| input.witness_utxo.clone()) - .collect(); + ) -> Result>, CompiledBinaryMarketExecutionError> { + let utxos = collect_witness_utxos(pset)?; let Some(target_utxo) = utxos.get(input_index) else { return Err(ProgramError::UtxoIndexOutOfBounds { input_index, utxo_count: utxos.len(), - }); + } + .into()); }; let expected_script = self.slot(slot).script_pubkey(); if target_utxo.script_pubkey != *expected_script { return Err(ProgramError::ScriptPubkeyMismatch { expected_hash: expected_script.script_hash().to_string(), actual_hash: target_utxo.script_pubkey.script_hash().to_string(), - }); + } + .into()); } + let annex = current_input_annex(pset, input_index); + Ok(ElementsEnv::new( - Arc::new(pset.extract_tx()?), + Arc::new(pset.extract_tx().map_err(ProgramError::TxExtraction)?), utxos .iter() .map(|utxo| ElementsUtxo { @@ -237,16 +314,46 @@ impl CompiledBinaryMarket { value: utxo.value, }) .collect(), - u32::try_from(input_index)?, + u32::try_from(input_index).map_err(ProgramError::InputIndexOverflow)?, self.compiled.commit().cmr(), self.slot(slot).control_block().clone(), - None, + annex, network.genesis_block_hash(), )) } } -#[derive(Debug, Error, PartialEq, Eq)] +fn collect_witness_utxos( + pset: &PartiallySignedTransaction, +) -> Result, CompiledBinaryMarketExecutionError> { + pset.inputs() + .iter() + .enumerate() + .map(|(input_index, input)| { + input + .witness_utxo + .clone() + .ok_or(CompiledBinaryMarketExecutionError::MissingWitnessUtxo { input_index }) + }) + .collect() +} + +fn current_input_annex(pset: &PartiallySignedTransaction, input_index: usize) -> Option> { + let witness_stack = pset + .inputs() + .get(input_index)? + .final_script_witness + .as_ref()?; + if witness_stack.len() < 2 { + return None; + } + witness_stack + .last() + .filter(|item| item.first() == Some(&TAPROOT_ANNEX_TAG)) + .cloned() +} + +#[derive(Debug, Error)] pub enum CompiledBinaryMarketError { #[error("{base_payout} is not a canonical v1 base payout")] InvalidBasePayout { base_payout: u64 }, @@ -256,8 +363,10 @@ pub enum CompiledBinaryMarketError { InvalidOraclePublicKey, #[error("binary-market collateral, outcome-token, and RT asset IDs must be distinct")] DuplicateAssetIds, - #[error("failed to compile binary-market SimplicityHL: {0}")] - Compilation(String), + #[error("failed to parse or analyze the canonical binary-market SimplicityHL template: {0}")] + TemplateCompilation(#[source] Arc), + #[error("failed to instantiate binary-market SimplicityHL arguments: {0}")] + ArgumentInstantiation(String), #[error("failed to build binary-market Taproot tree: {0}")] Taproot(#[from] TaprootBuilderError), #[error("compiled Taproot tree did not contain its program leaf")] @@ -274,6 +383,29 @@ pub enum CompiledBinaryMarketError { InconsistentRtValueCommitment, } +/// Errors raised while preparing or executing a compiled binary-market spend. +#[derive(Debug, Error)] +pub enum CompiledBinaryMarketExecutionError { + #[error("PSET input {input_index} is missing its witness_utxo")] + MissingWitnessUtxo { input_index: usize }, + #[error("PSET input {input_index} is missing its final_script_witness")] + MissingFinalScriptWitness { input_index: usize }, + #[error("finalized Simplicity CMR mismatch: expected {expected:?}, got {actual:?}")] + CmrMismatch { + expected: [u8; 32], + actual: [u8; 32], + }, + #[error("finalized Simplicity control block mismatch: expected {expected:?}, got {actual:?}")] + ControlBlockMismatch { + expected: Box, + actual: Box, + }, + #[error(transparent)] + FinalizedSpend(#[from] FinalizedSimplicitySpendError), + #[error(transparent)] + Program(#[from] ProgramError), +} + fn validate_params(params: BinaryMarketParams) -> Result<(), CompiledBinaryMarketError> { BinaryMarketEconomics::new(params.base_payout).map_err(|_| { CompiledBinaryMarketError::InvalidBasePayout { @@ -425,10 +557,15 @@ fn tap_data_hash(data: &[u8]) -> sha256::Hash { #[cfg(test)] mod tests { use std::collections::HashSet; + use std::sync::atomic::Ordering; - use elements::AssetId; + use elements::confidential::Nonce; + use elements::hashes::Hash as _; + use elements::pset::Input as PsetInput; use elements::schnorr::TweakedPublicKey; + use elements::{AssetId, OutPoint, TxOutWitness, Txid}; use simplex::provider::SimplicityNetwork; + use simplex::simplicityhl::Arguments; use super::*; @@ -449,6 +586,312 @@ mod tests { } } + fn txout(byte: u8, script_pubkey: Script) -> TxOut { + TxOut { + asset: Asset::Explicit(asset(byte)), + value: Value::Explicit(u64::from(byte)), + nonce: Nonce::Null, + script_pubkey, + witness: TxOutWitness::default(), + } + } + + fn pset_with_witness_utxos( + witness_utxos: impl IntoIterator>, + ) -> PartiallySignedTransaction { + let mut pset = PartiallySignedTransaction::new_v2(); + for (input_index, witness_utxo) in witness_utxos.into_iter().enumerate() { + let input_byte = u8::try_from(input_index + 1).expect("small test input index"); + let mut input = PsetInput::from_prevout(OutPoint::new( + Txid::from_byte_array([input_byte; 32]), + u32::try_from(input_index).expect("small test input index"), + )); + input.witness_utxo = witness_utxo; + pset.add_input(input); + } + pset + } + + fn simple_finalized_spend(control_block: &ControlBlock) -> FinalizedSimplicitySpend { + let compiled = CompiledProgram::new( + "fn main() { assert!(true); }", + Arguments::default(), + false, + Box::new(ElementsJetHinter), + ) + .expect("compile simple program"); + let satisfied = compiled + .satisfy(WitnessValues::default()) + .expect("satisfy simple program"); + let redeem_node = satisfied.redeem(); + let (program_bytes, witness_bytes) = redeem_node.to_vec_with_witness(); + FinalizedSimplicitySpend::from_core_stack([ + witness_bytes, + program_bytes, + redeem_node.cmr().as_ref().to_vec(), + control_block.serialize(), + ]) + .expect("build simple finalized spend") + } + + #[test] + fn analyzed_template_is_reused_and_instantiation_remains_deterministic() { + let first_template = binary_market_template().expect("analyze template"); + let second_template = binary_market_template().expect("reuse template"); + assert!(std::ptr::eq(first_template, second_template)); + assert_eq!(TEMPLATE_ANALYSIS_COUNT.load(Ordering::Relaxed), 1); + + let params = params(); + let first = CompiledBinaryMarket::new(params).expect("first instantiation"); + let repeated = CompiledBinaryMarket::new(params).expect("repeated instantiation"); + assert_eq!(first.cmr(), repeated.cmr()); + let mut changed = params; + changed.expiry_height += 1; + let second_market = + CompiledBinaryMarket::new(changed).expect("distinct market instantiation"); + assert_ne!(first.cmr(), second_market.cmr()); + + let error = first_template + .instantiate(Arguments::default(), false) + .map_err(CompiledBinaryMarketError::ArgumentInstantiation) + .expect_err("missing arguments must fail during instantiation"); + assert!(matches!( + error, + CompiledBinaryMarketError::ArgumentInstantiation(_) + )); + } + + #[test] + #[ignore = "manual compiler frontend benchmark"] + fn benchmark_cached_template_instantiation_against_full_compilation() { + use std::hint::black_box; + use std::time::Instant; + + const SAMPLES: usize = 5; + + let arguments = contract_arguments(params()).expect("derive benchmark arguments"); + let template = binary_market_template().expect("analyze cached template"); + + let cached_start = Instant::now(); + let mut cached_cmr = None; + for _ in 0..SAMPLES { + let compiled = template + .instantiate(arguments.build_arguments(), false) + .expect("instantiate cached template"); + cached_cmr = Some(black_box(compiled.commit().cmr())); + } + let cached_elapsed = cached_start.elapsed(); + + let full_start = Instant::now(); + let mut full_cmr = None; + for _ in 0..SAMPLES { + let compiled = CompiledProgram::new_with_unstable( + BinaryMarketProgram::SOURCE, + &UnstableFeatures::new([UnstableFeature::Imports]), + arguments.build_arguments(), + false, + Box::new(ElementsJetHinter), + ) + .expect("compile source from scratch"); + full_cmr = Some(black_box(compiled.commit().cmr())); + } + let full_elapsed = full_start.elapsed(); + + assert_eq!(cached_cmr, full_cmr); + eprintln!( + "samples={SAMPLES} cached_template_ns={} full_compilation_ns={}", + cached_elapsed.as_nanos(), + full_elapsed.as_nanos() + ); + } + + #[test] + fn witness_utxo_collection_preserves_pset_input_order() { + let expected = vec![ + txout(0x61, Script::from(vec![0x51])), + txout(0x62, Script::from(vec![0x52])), + txout(0x63, Script::from(vec![0x53])), + ]; + let pset = pset_with_witness_utxos(expected.iter().cloned().map(Some)); + + assert_eq!( + collect_witness_utxos(&pset).expect("complete witness UTXOs"), + expected + ); + } + + #[test] + fn missing_witness_utxo_before_target_does_not_shift_indices() { + let params = params(); + let compiled = CompiledBinaryMarket::new(params).expect("compile market"); + let slot = BinaryMarketSlot::UnresolvedCollateral; + let target = txout(0x61, compiled.slot(slot).script_pubkey().clone()); + let pset = pset_with_witness_utxos([None, Some(target)]); + let network = SimplicityNetwork::ElementsRegtest { + policy_asset: params.collateral_asset_id, + }; + + assert!(matches!( + compiled.environment(slot, &pset, 1, &network), + Err(CompiledBinaryMarketExecutionError::MissingWitnessUtxo { input_index: 0 }) + )); + } + + #[test] + fn missing_witness_utxo_at_target_reports_target_index() { + let params = params(); + let compiled = CompiledBinaryMarket::new(params).expect("compile market"); + let slot = BinaryMarketSlot::UnresolvedCollateral; + let decoy = txout(0x61, Script::from(vec![0x51])); + let pset = pset_with_witness_utxos([Some(decoy), None]); + let network = SimplicityNetwork::ElementsRegtest { + policy_asset: params.collateral_asset_id, + }; + + assert!(matches!( + compiled.environment(slot, &pset, 1, &network), + Err(CompiledBinaryMarketExecutionError::MissingWitnessUtxo { input_index: 1 }) + )); + } + + #[test] + fn missing_witness_utxo_after_target_is_also_rejected() { + let params = params(); + let compiled = CompiledBinaryMarket::new(params).expect("compile market"); + let slot = BinaryMarketSlot::UnresolvedCollateral; + let target = txout(0x61, compiled.slot(slot).script_pubkey().clone()); + let pset = pset_with_witness_utxos([Some(target), None]); + let network = SimplicityNetwork::ElementsRegtest { + policy_asset: params.collateral_asset_id, + }; + + assert!(matches!( + compiled.environment(slot, &pset, 0, &network), + Err(CompiledBinaryMarketExecutionError::MissingWitnessUtxo { input_index: 1 }) + )); + } + + #[test] + fn environment_uses_the_target_inputs_installed_annex() { + let params = params(); + let compiled = CompiledBinaryMarket::new(params).expect("compile market"); + let slot = BinaryMarketSlot::UnresolvedCollateral; + let target = txout(0x61, compiled.slot(slot).script_pubkey().clone()); + let mut pset = pset_with_witness_utxos([Some(target)]); + let annex = vec![TAPROOT_ANNEX_TAG, 0xaa, 0xbb]; + pset.inputs_mut()[0].final_script_witness = Some(vec![ + vec![0x01], + vec![0x02], + vec![0x03], + vec![0x04], + annex.clone(), + ]); + let network = SimplicityNetwork::ElementsRegtest { + policy_asset: params.collateral_asset_id, + }; + + let environment = compiled + .environment(slot, &pset, 0, &network) + .expect("build annex-bearing environment"); + assert_eq!(environment.annex(), Some(&annex)); + assert_eq!( + environment.tx().input[0].witness.script_witness.last(), + Some(&annex) + ); + } + + #[test] + fn nonfinal_annex_tag_is_not_treated_as_the_current_annex() { + let mut pset = pset_with_witness_utxos([Some(txout(0x61, Script::new()))]); + pset.inputs_mut()[0].final_script_witness = + Some(vec![vec![TAPROOT_ANNEX_TAG, 0xaa], vec![0x04]]); + + assert_eq!(current_input_annex(&pset, 0), None); + } + + #[test] + fn one_item_key_path_witness_is_not_treated_as_an_annex() { + let mut pset = pset_with_witness_utxos([Some(txout(0x61, Script::new()))]); + pset.inputs_mut()[0].final_script_witness = Some(vec![vec![TAPROOT_ANNEX_TAG, 0xaa]]); + + assert_eq!(current_input_annex(&pset, 0), None); + } + + #[test] + fn execute_finalized_requires_an_installed_final_witness() { + let params = params(); + let compiled = CompiledBinaryMarket::new(params).expect("compile market"); + let slot = BinaryMarketSlot::UnresolvedCollateral; + let target = txout(0x61, compiled.slot(slot).script_pubkey().clone()); + let pset = pset_with_witness_utxos([Some(target)]); + let network = SimplicityNetwork::ElementsRegtest { + policy_asset: params.collateral_asset_id, + }; + + assert!(matches!( + compiled.execute_finalized(slot, &pset, 0, &network), + Err(CompiledBinaryMarketExecutionError::MissingFinalScriptWitness { input_index: 0 }) + )); + } + + #[test] + fn execute_finalized_rejects_a_different_program_cmr() { + let params = params(); + let compiled = CompiledBinaryMarket::new(params).expect("compile market"); + let slot = BinaryMarketSlot::UnresolvedCollateral; + let finalized = simple_finalized_spend(compiled.slot(slot).control_block()); + assert_ne!(finalized.cmr(), compiled.cmr()); + let actual = finalized.cmr(); + let mut pset = pset_with_witness_utxos([Some(txout( + 0x61, + compiled.slot(slot).script_pubkey().clone(), + ))]); + pset.inputs_mut()[0].final_script_witness = Some(finalized.into_witness_stack()); + let network = SimplicityNetwork::ElementsRegtest { + policy_asset: params.collateral_asset_id, + }; + + assert!(matches!( + compiled.execute_finalized(slot, &pset, 0, &network), + Err(CompiledBinaryMarketExecutionError::CmrMismatch { + expected, + actual: found, + }) if expected == compiled.cmr() && found == actual + )); + } + + #[test] + fn execute_finalized_rejects_a_different_typed_control_block() { + let params = params(); + let mut compiled = CompiledBinaryMarket::new(params).expect("compile market"); + let slot = BinaryMarketSlot::UnresolvedCollateral; + let other_slot = BinaryMarketSlot::ResolvedYesCollateral; + let finalized = simple_finalized_spend(compiled.slot(other_slot).control_block()); + + // Isolate control-block validation by making this test-only clone expect + // the simple program's otherwise valid CMR. + compiled.cmr = finalized.cmr(); + let expected = compiled.slot(slot).control_block().clone(); + let actual = finalized.control_block().clone(); + assert_ne!(expected, actual); + let mut pset = pset_with_witness_utxos([Some(txout( + 0x61, + compiled.slot(slot).script_pubkey().clone(), + ))]); + pset.inputs_mut()[0].final_script_witness = Some(finalized.into_witness_stack()); + let network = SimplicityNetwork::ElementsRegtest { + policy_asset: params.collateral_asset_id, + }; + + assert!(matches!( + compiled.execute_finalized(slot, &pset, 0, &network), + Err(CompiledBinaryMarketExecutionError::ControlBlockMismatch { + expected: found_expected, + actual: found_actual, + }) if *found_expected == expected && *found_actual == actual + )); + } + #[test] fn generated_arguments_preserve_internal_asset_bytes_and_scalars() { let params = params(); @@ -591,25 +1034,25 @@ mod tests { fn invalid_params_fail_before_program_materialization() { let mut invalid = params(); invalid.base_payout = 999; - assert_eq!( + assert!(matches!( CompiledBinaryMarket::new(invalid).expect_err("invalid payout"), CompiledBinaryMarketError::InvalidBasePayout { base_payout: 999 } - ); + )); invalid = params(); invalid.expiry_height = 500_000_000; - assert_eq!( + assert!(matches!( CompiledBinaryMarket::new(invalid).expect_err("invalid expiry"), CompiledBinaryMarketError::InvalidExpiryHeight { - expiry_height: 500_000_000, + expiry_height: 500_000_000 } - ); + )); invalid = params(); invalid.no_token_asset_id = invalid.yes_token_asset_id; - assert_eq!( + assert!(matches!( CompiledBinaryMarket::new(invalid).expect_err("duplicate assets"), CompiledBinaryMarketError::DuplicateAssetIds - ); + )); } } diff --git a/crates/deadcat-contracts/src/finalized_spend.rs b/crates/deadcat-contracts/src/finalized_spend.rs new file mode 100644 index 0000000..73fc792 --- /dev/null +++ b/crates/deadcat-contracts/src/finalized_spend.rs @@ -0,0 +1,296 @@ +//! Typed, canonical finalized Simplicity script-path spends. + +use std::sync::Arc; + +use elements::taproot::{ControlBlock, TaprootError}; +use simplex::simplicityhl::simplicity::jet::Elements; +use simplex::simplicityhl::simplicity::{ + BitIter, DecodeError, HasCmr as _, NodeBounds, RedeemNode, +}; +use thiserror::Error; + +const CORE_STACK_ITEMS: usize = 4; +const WITNESS_ENCODING_INDEX: usize = 0; +const PROGRAM_ENCODING_INDEX: usize = 1; +const CMR_INDEX: usize = 2; +const CONTROL_BLOCK_INDEX: usize = 3; +const TAPROOT_ANNEX_TAG: u8 = 0x50; + +/// Encoded sizes of a finalized Simplicity script-path witness. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FinalizedSimplicitySpendSizes { + /// Raw bytes in the compact Simplicity witness encoding. + pub witness_bytes: usize, + /// Raw bytes in the Simplicity program encoding. + pub program_bytes: usize, + /// Raw bytes in the Taproot control block. + pub control_block_bytes: usize, + /// Raw bytes in the optional Taproot annex, including its `0x50` tag. + pub annex_bytes: usize, + /// Consensus-encoded size of the four-item core stack. + pub core_stack_bytes: usize, + /// Consensus-encoded size of the complete witness stack. + pub stack_bytes: usize, +} + +/// Errors constructing or decoding a finalized Simplicity spend. +#[derive(Debug, Error)] +pub enum FinalizedSimplicitySpendError { + #[error("expected exactly four core Simplicity stack items, got {len}")] + CoreStackShape { len: usize }, + #[error( + "expected four core Simplicity stack items plus at most one Taproot annex, got {len} items" + )] + WitnessStackShape { len: usize }, + #[error("fifth finalized Simplicity stack item is not a Taproot annex")] + InvalidAnnex, + #[error("encoded Simplicity CMR must be 32 bytes, got {len}")] + CmrLength { len: usize }, + #[error("failed to decode finalized Simplicity program: {0}")] + Decode(#[source] DecodeError), + #[error("decoded Simplicity CMR does not match the encoded CMR stack item")] + CmrMismatch, + #[error("invalid Taproot control block: {0}")] + InvalidControlBlock(#[source] TaprootError), + #[error( + "Taproot annex is not the canonical minimal Simplicity budget padding (expected {expected_len:?} bytes, got {actual_len:?})" + )] + NonCanonicalAnnex { + expected_len: Option, + actual_len: Option, + }, + #[error("canonical Simplicity budget padding does not provide a sufficient execution budget")] + InsufficientBudget, +} + +/// A decoded, canonically budgeted finalized Simplicity script-path spend. +/// +/// The serialized witness is always exactly +/// `[witness, program, cmr, control_block]`, followed by the exact minimal +/// budget annex when one is required. Fields stay private so callers cannot +/// invalidate the decoded node, CMR, or budget relationship. +#[derive(Clone)] +pub struct FinalizedSimplicitySpend { + witness_stack: Vec>, + redeem_node: Arc, + cmr: [u8; 32], + control_block: ControlBlock, + bounds: NodeBounds, + encoded_sizes: FinalizedSimplicitySpendSizes, +} + +impl FinalizedSimplicitySpend { + /// Decode a four-item core stack and add the exact minimal budget annex if + /// its execution cost requires one. + pub fn from_core_stack( + core_stack: [Vec; CORE_STACK_ITEMS], + ) -> Result { + let mut witness_stack = Vec::from(core_stack); + let (redeem_node, cmr, control_block) = decode_core_stack(&witness_stack)?; + let bounds = redeem_node.bounds(); + if let Some(annex) = bounds.cost.get_padding(&witness_stack) { + witness_stack.push(annex); + } + Self::from_decoded(witness_stack, redeem_node, cmr, control_block, bounds) + } + + /// Decode and validate an owned finalized witness stack. + /// + /// Any annex must be byte-for-byte equal to the minimal padding returned + /// for the decoded program's cost and four-item core stack. + pub fn from_witness_stack( + witness_stack: Vec>, + ) -> Result { + validate_witness_shape(&witness_stack)?; + let (redeem_node, cmr, control_block) = + decode_core_stack(&witness_stack[..CORE_STACK_ITEMS])?; + let bounds = redeem_node.bounds(); + Self::from_decoded(witness_stack, redeem_node, cmr, control_block, bounds) + } + + /// Decode and validate a borrowed finalized witness stack. + pub fn parse_witness_stack( + witness_stack: &[Vec], + ) -> Result { + Self::from_witness_stack(witness_stack.to_vec()) + } + + fn from_decoded( + witness_stack: Vec>, + redeem_node: Arc, + cmr: [u8; 32], + control_block: ControlBlock, + bounds: NodeBounds, + ) -> Result { + validate_witness_shape(&witness_stack)?; + let core_stack = witness_stack[..CORE_STACK_ITEMS].to_vec(); + let expected_annex = bounds.cost.get_padding(&core_stack); + let actual_annex = witness_stack.get(CORE_STACK_ITEMS); + if expected_annex.as_deref() != actual_annex.map(Vec::as_slice) { + return Err(FinalizedSimplicitySpendError::NonCanonicalAnnex { + expected_len: expected_annex.as_ref().map(Vec::len), + actual_len: actual_annex.map(Vec::len), + }); + } + if !bounds.cost.is_budget_valid(&witness_stack) { + return Err(FinalizedSimplicitySpendError::InsufficientBudget); + } + let encoded_sizes = FinalizedSimplicitySpendSizes { + witness_bytes: witness_stack[WITNESS_ENCODING_INDEX].len(), + program_bytes: witness_stack[PROGRAM_ENCODING_INDEX].len(), + control_block_bytes: witness_stack[CONTROL_BLOCK_INDEX].len(), + annex_bytes: actual_annex.map_or(0, Vec::len), + core_stack_bytes: elements::encode::serialize(&core_stack).len(), + stack_bytes: elements::encode::serialize(&witness_stack).len(), + }; + Ok(Self { + witness_stack, + redeem_node, + cmr, + control_block, + bounds, + encoded_sizes, + }) + } + + /// The decoded Simplicity redeem node, including its decoded witnesses. + #[must_use] + pub fn redeem_node(&self) -> &Arc { + &self.redeem_node + } + + /// The commitment Merkle root committed to by the stack. + #[must_use] + pub const fn cmr(&self) -> [u8; 32] { + self.cmr + } + + /// The parsed Taproot control block. + #[must_use] + pub const fn control_block(&self) -> &ControlBlock { + &self.control_block + } + + /// The canonical minimal budget annex, if one is required. + #[must_use] + pub fn annex(&self) -> Option<&[u8]> { + self.witness_stack.get(CORE_STACK_ITEMS).map(Vec::as_slice) + } + + /// Execution resource bounds of the decoded redeem node. + #[must_use] + pub const fn bounds(&self) -> NodeBounds { + self.bounds + } + + /// Raw and consensus-encoded sizes of this finalized witness. + #[must_use] + pub const fn encoded_sizes(&self) -> FinalizedSimplicitySpendSizes { + self.encoded_sizes + } + + /// The complete canonical witness stack. + #[must_use] + pub fn witness_stack(&self) -> &[Vec] { + &self.witness_stack + } + + /// Consume this value and return the complete canonical witness stack. + #[must_use] + pub fn into_witness_stack(self) -> Vec> { + self.witness_stack + } +} + +fn validate_witness_shape(witness_stack: &[Vec]) -> Result<(), FinalizedSimplicitySpendError> { + match witness_stack.len() { + CORE_STACK_ITEMS => Ok(()), + len if len == CORE_STACK_ITEMS + 1 => { + if witness_stack[CORE_STACK_ITEMS].first() == Some(&TAPROOT_ANNEX_TAG) { + Ok(()) + } else { + Err(FinalizedSimplicitySpendError::InvalidAnnex) + } + } + len => Err(FinalizedSimplicitySpendError::WitnessStackShape { len }), + } +} + +fn decode_core_stack( + core_stack: &[Vec], +) -> Result<(Arc, [u8; 32], ControlBlock), FinalizedSimplicitySpendError> { + if core_stack.len() != CORE_STACK_ITEMS { + return Err(FinalizedSimplicitySpendError::CoreStackShape { + len: core_stack.len(), + }); + } + if core_stack[CMR_INDEX].len() != 32 { + return Err(FinalizedSimplicitySpendError::CmrLength { + len: core_stack[CMR_INDEX].len(), + }); + } + let control_block = ControlBlock::from_slice(&core_stack[CONTROL_BLOCK_INDEX]) + .map_err(FinalizedSimplicitySpendError::InvalidControlBlock)?; + let redeem_node = RedeemNode::decode::<_, _, Elements>( + BitIter::from(core_stack[PROGRAM_ENCODING_INDEX].iter().copied()), + BitIter::from(core_stack[WITNESS_ENCODING_INDEX].iter().copied()), + ) + .map_err(FinalizedSimplicitySpendError::Decode)?; + if redeem_node.cmr().as_ref() != core_stack[CMR_INDEX].as_slice() { + return Err(FinalizedSimplicitySpendError::CmrMismatch); + } + let mut cmr = [0_u8; 32]; + cmr.copy_from_slice(&core_stack[CMR_INDEX]); + Ok((redeem_node, cmr, control_block)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_non_core_shapes_before_decode() { + let error = FinalizedSimplicitySpend::from_witness_stack(vec![Vec::new(); 3]) + .err() + .expect("bad shape"); + assert!(matches!( + error, + FinalizedSimplicitySpendError::WitnessStackShape { len: 3 } + )); + } + + #[test] + fn rejects_a_fifth_item_without_the_annex_tag() { + let error = FinalizedSimplicitySpend::from_witness_stack(vec![Vec::new(); 5]) + .err() + .expect("invalid annex"); + assert!(matches!(error, FinalizedSimplicitySpendError::InvalidAnnex)); + } + + #[test] + fn reports_the_cmr_length_separately_from_decode_errors() { + let error = FinalizedSimplicitySpend::from_witness_stack(vec![Vec::new(); 4]) + .err() + .expect("invalid CMR"); + assert!(matches!( + error, + FinalizedSimplicitySpendError::CmrLength { len: 0 } + )); + } + + #[test] + fn reports_invalid_control_blocks_separately_from_decode_errors() { + let error = FinalizedSimplicitySpend::from_witness_stack(vec![ + Vec::new(), + Vec::new(), + vec![0; 32], + Vec::new(), + ]) + .err() + .expect("invalid control block"); + assert!(matches!( + error, + FinalizedSimplicitySpendError::InvalidControlBlock(_) + )); + } +} diff --git a/crates/deadcat-contracts/src/interpret.rs b/crates/deadcat-contracts/src/interpret.rs index 3693124..4e66355 100644 --- a/crates/deadcat-contracts/src/interpret.rs +++ b/crates/deadcat-contracts/src/interpret.rs @@ -1,12 +1,14 @@ //! Confirmed-transaction decoding and typed covenant interpretation. +use elements::taproot::ControlBlock; use elements::{OutPoint, TxOut}; +use simplex::simplicityhl::simplicity::Value; use simplex::simplicityhl::simplicity::dag::{DagLike as _, InternalSharing}; -use simplex::simplicityhl::simplicity::jet::Elements; use simplex::simplicityhl::simplicity::node::Inner; -use simplex::simplicityhl::simplicity::{BitIter, HasCmr as _, RedeemNode, Value}; use thiserror::Error; +use crate::finalized_spend::{FinalizedSimplicitySpend, FinalizedSimplicitySpendError}; + mod binary_market; pub use binary_market::{ @@ -29,12 +31,10 @@ pub enum InterpretError { NotCovenantSpend, #[error("tracked contract output is inconsistent with its parameters/state: {0}")] InvalidTrackedOutput(&'static str), - #[error("taproot witness stack has unsupported shape after annex stripping (len {len})")] - BadWitnessStack { len: usize }, #[error("unexpected key-path spend")] UnexpectedKeySpend, - #[error("simplicity witness decode failed: {0}")] - Decode(String), + #[error("invalid finalized Simplicity spend: {0}")] + FinalizedSpend(#[from] FinalizedSimplicitySpendError), #[error("decoded Simplicity CMR does not match the compiled contract")] CmrMismatch, #[error("required decoded witness value is missing: {0}")] @@ -60,20 +60,24 @@ pub enum InterpretError { /// assuming a fixed positional ABI. #[derive(Clone)] pub struct DecodedSimplicityWitness { - cmr: [u8; 32], - control_block: Vec, + finalized_spend: FinalizedSimplicitySpend, values: Vec, } impl DecodedSimplicityWitness { #[must_use] pub const fn cmr(&self) -> [u8; 32] { - self.cmr + self.finalized_spend.cmr() + } + + #[must_use] + pub const fn control_block(&self) -> &ControlBlock { + self.finalized_spend.control_block() } #[must_use] - pub fn control_block(&self) -> &[u8] { - &self.control_block + pub const fn finalized_spend(&self) -> &FinalizedSimplicitySpend { + &self.finalized_spend } #[must_use] @@ -123,54 +127,26 @@ impl DecodedSimplicityWitness { } } -/// Remove a BIP341 annex from a finalized Taproot witness. -/// -/// Annex recognition requires at least two elements, preventing a key-spend -/// signature beginning with `0x50` from being mistaken for an annex. -#[must_use] -pub fn strip_taproot_annex(stack: &[Vec]) -> (&[Vec], Option<&[u8]>) { - if stack.len() >= 2 - && stack - .last() - .and_then(|element| element.first()) - .is_some_and(|byte| *byte == 0x50) - { - let (without, annex) = stack.split_at(stack.len() - 1); - (without, Some(annex[0].as_slice())) - } else { - (stack, None) - } -} - /// Decode the four-element smplx script-path stack -/// `[witness_bits, program_bits, cmr, control_block]`. +/// `[witness_bits, program_bits, cmr, control_block]` and validate its +/// canonical minimal budget annex. pub fn decode_simplicity_witness( stack: &[Vec], ) -> Result { - let (stack, _) = strip_taproot_annex(stack); - if stack.len() != 4 { - return Err(InterpretError::BadWitnessStack { len: stack.len() }); - } - let redeem = RedeemNode::decode::<_, _, Elements>( - BitIter::from(stack[1].iter().copied()), - BitIter::from(stack[0].iter().copied()), - ) - .map_err(|error| InterpretError::Decode(format!("{error:?}")))?; - if redeem.cmr().as_ref() != stack[2].as_slice() { - return Err(InterpretError::CmrMismatch); - } - let mut cmr = [0_u8; 32]; - cmr.copy_from_slice(&stack[2]); + let finalized_spend = FinalizedSimplicitySpend::parse_witness_stack(stack)?; let mut values = Vec::new(); - for item in redeem.as_ref().post_order_iter::() { + for item in finalized_spend + .redeem_node() + .as_ref() + .post_order_iter::() + { if let Inner::Witness(value) = item.node.inner() { values.push(value.shallow_clone()); } } Ok(DecodedSimplicityWitness { - cmr, - control_block: stack[3].clone(), + finalized_spend, values, }) } diff --git a/crates/deadcat-contracts/src/interpret/binary_market.rs b/crates/deadcat-contracts/src/interpret/binary_market.rs index 6953e30..32149ee 100644 --- a/crates/deadcat-contracts/src/interpret/binary_market.rs +++ b/crates/deadcat-contracts/src/interpret/binary_market.rs @@ -5,7 +5,7 @@ use elements::{AssetId, OutPoint, Transaction, TxOut}; use super::{ DecodedSimplicityWitness, InterpretError, TrackedContractOutput, decode_simplicity_witness, - locate_input, output_at, strip_taproot_annex, + locate_input, output_at, }; use crate::binary_market::{ AppliedBinaryMarketTransition, BinaryMarketAction, BinaryMarketEconomics, BinaryMarketSlot, @@ -114,8 +114,17 @@ pub fn interpret_binary_market_spend_with_compiled( let head_index = locate_input(transaction, head.outpoint)?; let input_base = u32::try_from(head_index).map_err(|_| InterpretError::IndexOverflow)?; let stack = &transaction.input[head_index].witness.script_witness; - let (core_stack, _) = strip_taproot_annex(stack); - if core_stack.len() == 1 { + let key_path_items = if stack.len() == 2 + && stack + .last() + .and_then(|item| item.first()) + .is_some_and(|byte| *byte == 0x50) + { + 1 + } else { + stack.len() + }; + if key_path_items == 1 { return Err(InterpretError::UnexpectedKeySpend); } let decoded = decode_simplicity_witness(stack)?; @@ -123,7 +132,7 @@ pub fn interpret_binary_market_spend_with_compiled( return Err(InterpretError::CmrMismatch); } let expected_slot = primary_slot(before); - if decoded.control_block() != compiled.slot(expected_slot).control_block().serialize() { + if decoded.control_block() != compiled.slot(expected_slot).control_block() { return Err(InterpretError::Inconsistent( "market control block mismatch", )); diff --git a/crates/deadcat-contracts/src/lib.rs b/crates/deadcat-contracts/src/lib.rs index cd9f140..543e33c 100644 --- a/crates/deadcat-contracts/src/lib.rs +++ b/crates/deadcat-contracts/src/lib.rs @@ -4,6 +4,7 @@ mod artifacts; pub mod binary_market; +pub mod finalized_spend; pub mod interpret; pub mod market_crypto; pub mod recovery; diff --git a/crates/deadcat-contracts/tests/covenant_execution.rs b/crates/deadcat-contracts/tests/covenant_execution.rs index 96ed03b..ec9bfa3 100644 --- a/crates/deadcat-contracts/tests/covenant_execution.rs +++ b/crates/deadcat-contracts/tests/covenant_execution.rs @@ -18,7 +18,7 @@ use elements::secp256k1_zkp::{Generator, Keypair, PedersenCommitment, Secp256k1, use elements::{ AssetId, ContractHash, LockTime, OutPoint, Script, Sequence, TxOut, TxOutWitness, Txid, }; -use simplex::program::{ProgramTrait as _, WitnessTrait as _}; +use simplex::program::WitnessTrait as _; use support::{asset, bare_op_return, explicit_txout, network, pset_input, pset_output}; @@ -162,12 +162,7 @@ fn execute_active_expiry_with_locktime( tokens_burned: 0, redeem_yes: false, }; - compiled.program(slot).as_ref().execute( - &pset, - &witness.build_witness(), - input_index, - &net, - )?; + compiled.execute(slot, &pset, &witness.build_witness(), input_index, &net)?; } Ok(()) } @@ -282,12 +277,7 @@ fn execute_initial_issuance( tokens_burned: 0, redeem_yes: false, }; - compiled.program(slot).as_ref().execute( - &pset, - &witness.build_witness(), - input_index, - &network, - )?; + compiled.execute(slot, &pset, &witness.build_witness(), input_index, &network)?; } Ok(()) } diff --git a/crates/deadcat-contracts/tests/interpret.rs b/crates/deadcat-contracts/tests/interpret.rs index f3563da..a8e9be0 100644 --- a/crates/deadcat-contracts/tests/interpret.rs +++ b/crates/deadcat-contracts/tests/interpret.rs @@ -5,6 +5,7 @@ mod support; use deadcat_contracts::binary_market::{ BinaryMarketAction, BinaryMarketSlot, CompiledBinaryMarket, derived_binary_market, }; +use deadcat_contracts::finalized_spend::FinalizedSimplicitySpendError; use deadcat_contracts::interpret::{ BinaryMarketLiveOutputs, BinaryMarketPath, InterpretError, TrackedContractOutput, interpret_binary_market_spend, @@ -16,7 +17,7 @@ use elements::hashes::Hash as _; use elements::pset::PartiallySignedTransaction; use elements::secp256k1_zkp::{Generator, Keypair, PedersenCommitment, Secp256k1, Tweak}; use elements::{LockTime, OutPoint, Script, Sequence, Transaction, TxOut, TxOutWitness}; -use simplex::program::{ProgramTrait as _, WitnessTrait as _}; +use simplex::program::WitnessTrait as _; use support::{asset, bare_op_return, explicit_txout, network, pset_input, pset_output, script}; @@ -45,10 +46,11 @@ struct BinaryScenario { params: BinaryMarketParams, before: BinaryMarketState, live: BinaryMarketLiveOutputs, + pset: PartiallySignedTransaction, transaction: Transaction, } -fn resolved_redemption_scenario(full: bool, decoy: bool, annex: bool) -> BinaryScenario { +fn resolved_redemption_scenario(full: bool, decoy: bool) -> BinaryScenario { let params = binary_params(); let compiled = CompiledBinaryMarket::new(params).expect("compile market"); let before = BinaryMarketState::ResolvedYes { @@ -136,16 +138,18 @@ fn resolved_redemption_scenario(full: bool, decoy: bool, annex: bool) -> BinaryS redeem_yes: false, }; let net = network(params.collateral_asset_id); - let mut stack = compiled - .program(BinaryMarketSlot::ResolvedYesCollateral) - .as_ref() - .finalize(&pset, &witness.build_witness(), 0, &net) - .expect("finalize redemption"); - if annex { - stack.push(vec![0x50, 0x01]); - } - let mut transaction = pset.extract_tx().expect("extract market tx"); - transaction.input[0].witness.script_witness = stack; + let stack = compiled + .finalize( + BinaryMarketSlot::ResolvedYesCollateral, + &pset, + &witness.build_witness(), + 0, + &net, + ) + .expect("finalize redemption") + .into_witness_stack(); + pset.inputs_mut()[0].final_script_witness = Some(stack); + let transaction = pset.extract_tx().expect("extract market tx"); BinaryScenario { params, before, @@ -157,13 +161,14 @@ fn resolved_redemption_scenario(full: bool, decoy: bool, annex: bool) -> BinaryS txout: live_txout, }), }, + pset, transaction, } } #[test] fn interprets_partial_and_full_market_redemptions() { - let partial = resolved_redemption_scenario(false, false, true); + let partial = resolved_redemption_scenario(false, false); let interpreted = interpret_binary_market_spend( partial.params, partial.before, @@ -187,7 +192,7 @@ fn interprets_partial_and_full_market_redemptions() { ); assert_eq!(interpreted.continuations[0].output.outpoint.vout, 0); - let full = resolved_redemption_scenario(true, false, false); + let full = resolved_redemption_scenario(true, false); let interpreted = interpret_binary_market_spend(full.params, full.before, &full.live, &full.transaction) .expect("interpret full redemption"); @@ -200,9 +205,54 @@ fn interprets_partial_and_full_market_redemptions() { assert!(interpreted.continuations.is_empty()); } +#[test] +fn market_interpreter_rejects_a_noncanonical_annex() { + let mut scenario = resolved_redemption_scenario(false, false); + let stack = &mut scenario.transaction.input[0].witness.script_witness; + if stack.len() == 5 { + stack.pop().expect("canonical annex"); + } + stack.push(vec![0x50, 0x01]); + + let error = interpret_binary_market_spend( + scenario.params, + scenario.before, + &scenario.live, + &scenario.transaction, + ) + .expect_err("noncanonical annex must fail closed"); + assert!(matches!( + error, + InterpretError::FinalizedSpend(FinalizedSimplicitySpendError::NonCanonicalAnnex { + actual_len: Some(2), + .. + }) + )); +} + +#[test] +fn installed_finalized_market_witness_reexecutes() { + let scenario = resolved_redemption_scenario(false, false); + let compiled = CompiledBinaryMarket::new(scenario.params).expect("compile market"); + let net = network(scenario.params.collateral_asset_id); + + compiled + .execute_finalized( + BinaryMarketSlot::ResolvedYesCollateral, + &scenario.pset, + 0, + &net, + ) + .expect("re-execute installed finalized witness"); + assert_eq!( + scenario.pset.extract_tx().expect("extract finalized PSET"), + scenario.transaction + ); +} + #[test] fn market_interpreter_uses_witness_output_base_not_first_matching_script() { - let scenario = resolved_redemption_scenario(false, true, false); + let scenario = resolved_redemption_scenario(false, true); let interpreted = interpret_binary_market_spend( scenario.params, scenario.before, @@ -216,7 +266,7 @@ fn market_interpreter_uses_witness_output_base_not_first_matching_script() { #[test] fn market_interpreter_rejects_tampered_control_blocks() { - let mut market = resolved_redemption_scenario(false, false, false); + let mut market = resolved_redemption_scenario(false, false); let compiled = CompiledBinaryMarket::new(market.params).expect("compile market"); market.transaction.input[0].witness.script_witness[3] = compiled .slot(BinaryMarketSlot::ResolvedNoCollateral) @@ -356,12 +406,17 @@ fn finalized_active_expiry(side: RtSide, sequences: [Sequence; 3]) -> BinaryScen }; let net = network(params.collateral_asset_id); let stack = compiled - .program(BinaryMarketSlot::UnresolvedYesRt) - .as_ref() - .finalize(&pset, &witness.build_witness(), 0, &net) - .expect("finalize active expiry"); - let mut transaction = pset.extract_tx().expect("extract expiry"); - transaction.input[0].witness.script_witness = stack; + .finalize( + BinaryMarketSlot::UnresolvedYesRt, + &pset, + &witness.build_witness(), + 0, + &net, + ) + .expect("finalize active expiry") + .into_witness_stack(); + pset.inputs_mut()[0].final_script_witness = Some(stack); + let transaction = pset.extract_tx().expect("extract expiry"); BinaryScenario { params, before, @@ -379,6 +434,7 @@ fn finalized_active_expiry(side: RtSide, sequences: [Sequence; 3]) -> BinaryScen txout: collateral_txout, }), }, + pset, transaction, } } @@ -586,10 +642,15 @@ fn interprets_partial_cancellation_when_path_equals_slot_and_bases_are_shared() }; let net = network(params.collateral_asset_id); let stack = compiled - .program(BinaryMarketSlot::UnresolvedYesRt) - .as_ref() - .finalize(&pset, &witness.build_witness(), 0, &net) - .expect("finalize partial cancellation"); + .finalize( + BinaryMarketSlot::UnresolvedYesRt, + &pset, + &witness.build_witness(), + 0, + &net, + ) + .expect("finalize partial cancellation") + .into_witness_stack(); let mut transaction = pset.extract_tx().expect("extract cancellation"); transaction.input[0].witness.script_witness = stack; let live = BinaryMarketLiveOutputs { diff --git a/crates/deadcat-node/src/registration.rs b/crates/deadcat-node/src/registration.rs index dad054e..1643321 100644 --- a/crates/deadcat-node/src/registration.rs +++ b/crates/deadcat-node/src/registration.rs @@ -5,7 +5,9 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::str::FromStr as _; use std::sync::Arc; -use deadcat_contracts::binary_market::{BinaryMarketSlot, CompiledBinaryMarket}; +use deadcat_contracts::binary_market::{ + BinaryMarketSlot, CompiledBinaryMarket, CompiledBinaryMarketError, +}; use deadcat_contracts::market_crypto::derive_issuance_assets; use deadcat_contracts::recovery::{ MARKET_V1_TAG, MarketCollateral, MarketRecoveryHint, validate_recovery_txout, @@ -398,8 +400,7 @@ pub(crate) fn verify_binary_market_creation_shared( } }; - let compiled = CompiledBinaryMarket::new(params) - .map_err(|error| RegistrationError::Compilation(error.to_string()))?; + let compiled = CompiledBinaryMarket::new(params)?; // Canonical lineage always starts with both RT legs on side A. let yes_commitments = commitments( params.yes_reissuance_token_id, @@ -644,7 +645,7 @@ pub enum RegistrationError { #[error("invalid contract package: {0}")] InvalidPackage(String), #[error("contract compilation failed: {0}")] - Compilation(String), + Compilation(#[from] CompiledBinaryMarketError), #[error("invalid contract creation: {0}")] InvalidCreation(String), }