From ec6a2ac13086f019de3f71f69a91b26435b8a592 Mon Sep 17 00:00:00 2001 From: Artem Chystiakov Date: Fri, 7 Aug 2026 19:27:07 +0300 Subject: [PATCH 1/2] simplex wasm compatibility --- .gitignore | 3 + Cargo.lock | 86 ++- Cargo.toml | 3 +- crates/build/src/generator.rs | 20 +- crates/cli/Cargo.toml | 2 +- crates/regtest/Cargo.toml | 2 +- crates/sdk/Cargo.toml | 8 +- crates/sdk/src/program/core.rs | 162 +++-- crates/sdk/src/provider/core.rs | 2 + crates/sdk/src/provider/error.rs | 2 + crates/sdk/src/provider/mod.rs | 11 +- crates/sdk/src/signer/core.rs | 165 +++-- crates/sdk/src/signer/error.rs | 14 + crates/sdk/src/transaction/change_output.rs | 33 + .../sdk/src/transaction/final_transaction.rs | 27 + crates/sdk/src/transaction/mod.rs | 6 +- crates/sdk/src/transaction/partial_input.rs | 22 + crates/simplex/Cargo.toml | 2 +- crates/test/Cargo.toml | 2 +- crates/test/src/context.rs | 10 +- crates/wasm/Cargo.toml | 25 + crates/wasm/build.sh | 63 ++ crates/wasm/src/lib.rs | 564 ++++++++++++++++++ examples/basic/tests/basic_test.rs | 2 +- fixtures/simf/tapleaf_check.simf | 7 + fixtures/tests/basic_test.rs | 2 +- fixtures/tests/log_level.rs | 2 +- fixtures/tests/multidep_test.rs | 2 +- fixtures/tests/nested_sig.rs | 2 +- fixtures/tests/tapleaf_test.rs | 35 ++ 30 files changed, 1116 insertions(+), 170 deletions(-) create mode 100644 crates/sdk/src/transaction/change_output.rs create mode 100644 crates/wasm/Cargo.toml create mode 100755 crates/wasm/build.sh create mode 100644 crates/wasm/src/lib.rs create mode 100644 fixtures/simf/tapleaf_check.simf create mode 100644 fixtures/tests/tapleaf_test.rs diff --git a/.gitignore b/.gitignore index 6a5391b7..f73e6016 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ target/ # Debugging data logs.md tmp_vendor + +# wasm-bindgen output, built by crates/wasm/build.sh +crates/wasm/pkg/ diff --git a/Cargo.lock b/Cargo.lock index eae9b7b8..e2f0ac2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -606,30 +606,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -830,12 +806,11 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" dependencies = [ - "cfg-if", - "futures-util", + "once_cell", "wasm-bindgen", ] @@ -991,12 +966,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - [[package]] name = "pin-utils" version = "0.1.0" @@ -1375,12 +1344,6 @@ dependencies = [ "simplicity-lang", ] -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - [[package]] name = "smplx-build" version = "0.0.9" @@ -1489,6 +1452,18 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "smplx-wasm" +version = "0.0.9" +dependencies = [ + "elements-miniscript", + "hex", + "serde_json", + "simplicityhl", + "smplx-sdk", + "wasm-bindgen", +] + [[package]] name = "stacker" version = "0.1.24" @@ -1742,22 +1717,35 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1765,22 +1753,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ - "bumpalo", "proc-macro2", "quote", "syn", + "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" dependencies = [ "unicode-ident", ] diff --git a/Cargo.toml b/Cargo.toml index 2ea0ca47..d133e5ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,9 @@ smplx-macros = { path = "./crates/macros", version = "0.0.9" } smplx-build = { path = "./crates/build", version = "0.0.9" } smplx-test = { path = "./crates/test", version = "0.0.9" } smplx-regtest = { path = "./crates/regtest", version = "0.0.9" } -smplx-sdk = { path = "./crates/sdk", version = "0.0.9" } +smplx-sdk = { path = "./crates/sdk", version = "0.0.9", default-features = false } smplx-std = { path = "./crates/simplex", version = "0.0.9" } +smplx-wasm = { path = "./crates/wasm", version = "0.0.9" } serde = { version = "1.0.228", features = ["derive"] } hex = { version = "0.4.3" } diff --git a/crates/build/src/generator.rs b/crates/build/src/generator.rs index 5dd0e8fd..3989a70e 100644 --- a/crates/build/src/generator.rs +++ b/crates/build/src/generator.rs @@ -265,9 +265,9 @@ impl ArtifactsGenerator { pub const SOURCE: &'static str = #include_simf_module::#include_simf_source_const; #[must_use] - pub fn new(arguments: impl ArgumentsTrait + 'static) -> Self { + pub fn new(arguments: &impl ArgumentsTrait) -> Self { Self { - program: Program::new(Self::SOURCE, Box::new(arguments)), + program: Program::new(Self::SOURCE, arguments), } } @@ -284,7 +284,7 @@ impl ArtifactsGenerator { } #[must_use] - pub fn set_storage_at(&mut self, index: usize, new_value: [u8; 32]) { + pub fn set_storage_at(&mut self, index: usize, new_value: impl Into>) { self.program.set_storage_at(index, new_value); } @@ -294,12 +294,12 @@ impl ArtifactsGenerator { } #[must_use] - pub fn get_storage(&self) -> &[[u8; 32]] { + pub fn get_storage(&self) -> &[Vec] { self.program.get_storage() } #[must_use] - pub fn get_storage_at(&self, index: usize) -> [u8; 32] { + pub fn get_storage_at(&self, index: usize) -> Vec { self.program.get_storage_at(index) } @@ -312,6 +312,16 @@ impl ArtifactsGenerator { pub fn get_script_hash(&self, network: &SimplicityNetwork) -> [u8; 32] { self.program.get_script_hash(network) } + + #[must_use] + pub fn get_cmr(&self) -> [u8; 32] { + self.program.get_cmr() + } + + #[must_use] + pub fn get_tapleaf_hash(&self) -> [u8; 32] { + self.program.get_tapleaf_hash() + } } impl AsRef for #program_name { diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index f43ac296..c4b09343 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -18,7 +18,7 @@ workspace = true smplx-regtest = { workspace = true } smplx-test = { workspace = true } smplx-build = { workspace = true } -smplx-sdk = { workspace = true } +smplx-sdk = { workspace = true, features = ["provider"] } thiserror = { workspace = true } serde = { workspace = true } diff --git a/crates/regtest/Cargo.toml b/crates/regtest/Cargo.toml index f80029f4..dad442c7 100644 --- a/crates/regtest/Cargo.toml +++ b/crates/regtest/Cargo.toml @@ -11,7 +11,7 @@ readme = "../../README.md" workspace = true [dependencies] -smplx-sdk = { workspace = true } +smplx-sdk = { workspace = true, features = ["provider"] } thiserror = { workspace = true } electrsd = { workspace = true } diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index 251db571..6a4cdbf0 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -10,12 +10,16 @@ readme = "../../README.md" [lints] workspace = true +[features] +default = ["provider"] +provider = ["dep:minreq", "dep:bitcoincore-rpc"] + [dependencies] thiserror = { workspace = true } sha2 = { workspace = true } -minreq = { workspace = true } +minreq = { workspace = true, optional = true } simplicityhl = { workspace = true } -bitcoincore-rpc = { workspace = true } +bitcoincore-rpc = { workspace = true, optional = true } serde = { workspace = true } hex = { workspace = true } diff --git a/crates/sdk/src/program/core.rs b/crates/sdk/src/program/core.rs index d683f359..3c7f281e 100644 --- a/crates/sdk/src/program/core.rs +++ b/crates/sdk/src/program/core.rs @@ -1,6 +1,6 @@ -use std::iter; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; +use bitcoin_hashes::Hash; use dyn_clone::DynClone; use simplicityhl::ast::ElementsJetHinter; @@ -9,8 +9,8 @@ use simplicityhl::elements::{Address, Script, Transaction, TxOut, taproot}; use simplicityhl::simplicity::bitcoin::{XOnlyPublicKey, secp256k1}; use simplicityhl::simplicity::jet::elements::{ElementsEnv, ElementsUtxo}; use simplicityhl::simplicity::{BitMachine, RedeemNode, Value, leaf_version}; +use simplicityhl::{Arguments, Parameters, WitnessTypes, WitnessValues}; use simplicityhl::{CompiledProgram, UnstableFeatures}; -use simplicityhl::{Parameters, WitnessTypes, WitnessValues}; use crate::global::GlobalConfig; use crate::program::logger::ProgramLogger; @@ -77,15 +77,18 @@ pub trait ProgramTrait: DynClone { ) -> Result>, ProgramError>; } -/// Represents a program structure containing its source, a public key, arguments, and associated storage. +/// Represents a program structure containing its public key, compiled program, and associated storage. +/// A compiled program acts as a cache, instantiated during "loading". /// /// Abstraction giving the power to execute Simplicity contracts without specifying any additional parameters. #[derive(Clone)] pub struct Program { - source: &'static str, + source: Arc, + arguments: Arguments, pub_key: XOnlyPublicKey, - arguments: Box, - storage: Vec<[u8; 32]>, + storage: Vec>, + include_debug_symbols: Option, + compiled: Arc>, } dyn_clone::clone_trait_object!(ProgramTrait); @@ -199,12 +202,14 @@ impl ProgramTrait for Program { impl Program { /// Creates a new instance of the struct with the provided source string and arguments. #[must_use] - pub fn new(source: &'static str, arguments: Box) -> Self { + pub fn new(source: impl Into>, arguments: &dyn ArgumentsTrait) -> Self { Self { - source, + source: source.into(), pub_key: tr_unspendable_key(), - arguments, + arguments: arguments.build_arguments(), storage: Vec::new(), + include_debug_symbols: None, + compiled: Arc::new(OnceLock::new()), } } @@ -217,10 +222,20 @@ impl Program { self } + /// Builds this program in the mode the protocol declares. + #[must_use] + pub fn with_debug_symbols(mut self, include: bool) -> Self { + self.include_debug_symbols = Some(include); + // This changes the output CMR, so we need to update the cache + self.compiled = Arc::new(OnceLock::new()); + + self + } + /// Sets storage capacity for further usage. #[must_use] pub fn with_storage_capacity(mut self, capacity: usize) -> Self { - self.storage = vec![[0u8; 32]; capacity]; + self.storage = vec![vec![0u8; 32]; capacity]; self } @@ -229,10 +244,10 @@ impl Program { /// /// # Panics /// Panics if the `index` is out of bounds for the initiasized storage. - pub fn set_storage_at(&mut self, index: usize, new_value: [u8; 32]) { + pub fn set_storage_at(&mut self, index: usize, new_value: impl Into>) { let slot = self.storage.get_mut(index).expect("Index out of bounds"); - *slot = new_value; + *slot = new_value.into(); } /// Returns the number of storage chunks for a program. @@ -243,7 +258,7 @@ impl Program { /// Returns storage as a whole array of 32-byte chunks. #[must_use] - pub fn get_storage(&self) -> &[[u8; 32]] { + pub fn get_storage(&self) -> &[Vec] { &self.storage } @@ -252,8 +267,8 @@ impl Program { /// # Panics /// Panics if the `index` is out of bounds for the initiated storage. #[must_use] - pub fn get_storage_at(&self, index: usize) -> [u8; 32] { - self.storage[index] + pub fn get_storage_at(&self, index: usize) -> Vec { + self.storage[index].clone() } /// Returns a taproot address for a defined `SimplicityNetwork`. @@ -285,13 +300,33 @@ impl Program { hash_script(&self.get_script_pubkey(network)) } + /// Compiles the program and returns its Commitment Merkle Root. + /// + /// # Panics + /// Panics if the SimplicityHL compilation fails. + pub fn get_cmr(&self) -> [u8; 32] { + self.load().unwrap().commit().cmr().to_byte_array() + } + + /// Returns the 32-byte tapleaf hash of the program's Simplicity script. + /// + /// # Panics + /// Panics if the SimplicityHL compilation fails. + pub fn get_tapleaf_hash(&self) -> [u8; 32] { + let (script, version) = self.script_version().unwrap(); + + taproot::TapLeafHash::from_script(&script, version).to_byte_array() + } + /// Retrieves program ABI metadata for argument types. /// /// # Errors /// Returns a `ProgramError` if compilation fails or generating ABI metadata fails. pub fn get_argument_types(&self) -> Result { - let compiled = self.load()?; - let abi_meta = compiled.generate_abi_meta().map_err(ProgramError::ProgramGenAbiMeta)?; + let abi_meta = self + .load()? + .generate_abi_meta() + .map_err(ProgramError::ProgramGenAbiMeta)?; Ok(abi_meta.param_types) } @@ -301,23 +336,32 @@ impl Program { /// # Errors /// Returns a `ProgramError` if compilation fails or generating ABI metadata fails. pub fn get_witness_types(&self) -> Result { - let compiled = self.load()?; - let abi_meta = compiled.generate_abi_meta().map_err(ProgramError::ProgramGenAbiMeta)?; + let abi_meta = self + .load()? + .generate_abi_meta() + .map_err(ProgramError::ProgramGenAbiMeta)?; Ok(abi_meta.witness_types) } - fn load(&self) -> Result { + fn load(&self) -> Result<&CompiledProgram, ProgramError> { + // Check cache first + if let Some(compiled) = self.compiled.get() { + return Ok(compiled); + } + let compiled = CompiledProgram::new_with_unstable( - self.source, + Arc::clone(&self.source), &UnstableFeatures::all(), - self.arguments.build_arguments(), - GlobalConfig::get_include_debug_symbols(), + self.arguments.clone(), + self.include_debug_symbols + .unwrap_or_else(GlobalConfig::get_include_debug_symbols), Box::new(ElementsJetHinter), ) .map_err(ProgramError::Compilation)?; - Ok(compiled) + // Update the cache + Ok(self.compiled.get_or_init(|| compiled)) } fn script_version(&self) -> Result<(Script, taproot::LeafVersion), ProgramError> { @@ -327,21 +371,18 @@ impl Program { Ok((script, leaf_version())) } + /// Depths of a left-folded tap tree, in the order `TaprootBuilder` wants them. + /// + /// The tree is `tapbranch(tapbranch(tapbranch(cmr, e1), e2), e3)`: the program's own leaf + /// and the first extra leaf sit deepest, and each further leaf is one level shallower. fn taproot_leaf_depths(total_leaves: usize) -> Vec { assert!(total_leaves > 0, "Taproot tree must contain at least one leaf"); - let next_pow2 = total_leaves.next_power_of_two(); - let depth = next_pow2.ilog2() as usize; - - let shallow_count = next_pow2 - total_leaves; - let deep_count = total_leaves - shallow_count; - + let extra = total_leaves - 1; let mut depths = Vec::with_capacity(total_leaves); - depths.extend(iter::repeat_n(depth, deep_count)); - if depth > 0 { - depths.extend(iter::repeat_n(depth - 1, shallow_count)); - } + depths.push(extra); + depths.extend((1..=extra).rev()); depths } @@ -409,7 +450,7 @@ mod tests { } fn dummy_program() -> Program { - Program::new(DUMMY_PROGRAM, Box::new(EmptyArguments)) + Program::new(DUMMY_PROGRAM, &EmptyArguments) } fn dummy_network() -> SimplicityNetwork { @@ -435,6 +476,27 @@ mod tests { pst } + #[test] + fn compiles_once_and_keeps_it() { + let program = dummy_program(); + + let first = program.load().expect("the dummy program compiles"); + let second = program.load().expect("the dummy program compiles"); + + assert!(std::ptr::eq(first, second), "the program was compiled twice"); + } + + #[test] + fn changed_build_mode_is_not_served_the_old_compilation() { + let plain = dummy_program(); + let plain_cmr = plain.get_cmr(); + + let debug = dummy_program().with_debug_symbols(true); + let debug_cmr = debug.get_cmr(); + + assert_ne!(plain_cmr, debug_cmr, "the build mode did not reach the compiler"); + } + #[test] fn test_get_env_idx() { let program = dummy_program(); @@ -467,19 +529,19 @@ mod tests { } #[test] - fn test_taproot_leaf_depths_known_values() { - let cases = [ - (1, vec![0]), - (2, vec![1, 1]), - (3, vec![2, 2, 1]), - (4, vec![2, 2, 2, 2]), - (5, vec![3, 3, 2, 2, 2]), - (6, vec![3, 3, 3, 3, 2, 2]), - (8, vec![3, 3, 3, 3, 3, 3, 3, 3]), - ]; - - for (n, expected) in cases { - assert_eq!(Program::taproot_leaf_depths(n), expected, "n={n}"); - } + fn left_folded_depths() { + assert_eq!(Program::taproot_leaf_depths(1), vec![0]); + assert_eq!(Program::taproot_leaf_depths(2), vec![1, 1]); + assert_eq!(Program::taproot_leaf_depths(3), vec![2, 2, 1]); + assert_eq!(Program::taproot_leaf_depths(4), vec![3, 3, 2, 1]); + assert_eq!(Program::taproot_leaf_depths(5), vec![4, 4, 3, 2, 1]); + } + + // The measured boundary: a balanced tree agrees with a left fold up to three leaves and + // diverges from four. Four leaves balanced is [2, 2, 2, 2]; left-folded it is not. + #[test] + fn diverges_from_a_balanced_tree_at_four_leaves() { + assert_eq!(Program::taproot_leaf_depths(3), vec![2, 2, 1]); + assert_ne!(Program::taproot_leaf_depths(4), vec![2, 2, 2, 2]); } } diff --git a/crates/sdk/src/provider/core.rs b/crates/sdk/src/provider/core.rs index dccc0cf7..11b7f958 100644 --- a/crates/sdk/src/provider/core.rs +++ b/crates/sdk/src/provider/core.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; +#[cfg(feature = "provider")] use bitcoincore_rpc::Auth; use simplicityhl::elements::{Address, Script, Transaction, Txid}; @@ -15,6 +16,7 @@ pub const DEFAULT_FEE_RATE: f32 = 100.0; pub const DEFAULT_ESPLORA_TIMEOUT_SECS: u64 = 10; /// Contains foundational configuration elements required for initializing a generic blockchain provider. +#[cfg(feature = "provider")] #[derive(Debug, Clone)] pub struct ProviderInfo { /// URL of the target Esplora REST service. diff --git a/crates/sdk/src/provider/error.rs b/crates/sdk/src/provider/error.rs index c0322302..2aa89b74 100644 --- a/crates/sdk/src/provider/error.rs +++ b/crates/sdk/src/provider/error.rs @@ -1,9 +1,11 @@ +#[cfg(feature = "provider")] use crate::provider::rpc::error::RpcError; /// Defines standard errors possible when using a blockchain interaction provider. #[derive(Debug, thiserror::Error)] pub enum ProviderError { /// Wrapper around an RPC-level error representing transport or network-level connectivity failures to the inner node. + #[cfg(feature = "provider")] #[error(transparent)] Rpc(#[from] RpcError), diff --git a/crates/sdk/src/provider/mod.rs b/crates/sdk/src/provider/mod.rs index a476aa94..34b5b162 100644 --- a/crates/sdk/src/provider/mod.rs +++ b/crates/sdk/src/provider/mod.rs @@ -3,20 +3,29 @@ pub mod core; /// Provider-specific error enumerations for handling transmission, retrieval, or interpretation issues. pub mod error; /// Types and definitions for interacting specifically with an Esplora REST API provider backend. +#[cfg(feature = "provider")] pub mod esplora; /// Definitions distinguishing blockchain network states (e.g. mainnet, testnet, regtest) and related configurations. pub mod network; /// Submodules and definitions handling direct JSON-RPC interfacing with backing Bitcoin/Elements core nodes. +#[cfg(feature = "provider")] pub mod rpc; /// Abstractions and composite providers intended for general usage in the Simplex SDK. +#[cfg(feature = "provider")] pub mod simplex; -pub use core::{ProviderInfo, ProviderTrait}; +#[cfg(feature = "provider")] +pub use core::ProviderInfo; +pub use core::ProviderTrait; +#[cfg(feature = "provider")] pub use esplora::EsploraProvider; +#[cfg(feature = "provider")] pub use rpc::elements::ElementsRpc; +#[cfg(feature = "provider")] pub use simplex::SimplexProvider; pub use network::*; pub use error::ProviderError; +#[cfg(feature = "provider")] pub use rpc::error::RpcError; diff --git a/crates/sdk/src/signer/core.rs b/crates/sdk/src/signer/core.rs index 8263a115..cb71cd7e 100644 --- a/crates/sdk/src/signer/core.rs +++ b/crates/sdk/src/signer/core.rs @@ -1,4 +1,6 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; +#[cfg(feature = "provider")] +use std::collections::HashSet; use std::str::FromStr; use std::sync::Arc; @@ -6,7 +8,9 @@ use simplicityhl::Value; use simplicityhl::WitnessValues; use simplicityhl::elements::pset::PartiallySignedTransaction; use simplicityhl::elements::secp256k1_zkp::{All, Keypair, Message, Secp256k1, ecdsa, schnorr}; -use simplicityhl::elements::{Address, AssetId, OutPoint, Script, Transaction, Txid}; +use simplicityhl::elements::{Address, Script, Transaction}; +#[cfg(feature = "provider")] +use simplicityhl::elements::{AssetId, OutPoint, Txid}; use simplicityhl::simplicity::bitcoin::XOnlyPublicKey; use simplicityhl::simplicity::hashes::Hash; use simplicityhl::str::WitnessName; @@ -31,10 +35,13 @@ use elements_miniscript::{ use crate::constants::MIN_FEE; use crate::program::ProgramTrait; use crate::program::logger::ProgramLogger; +#[cfg(feature = "provider")] use crate::provider::ProviderTrait; use crate::provider::SimplicityNetwork; use crate::signer::wtns_injector::WtnsInjector; -use crate::transaction::{FinalTransaction, PartialInput, PartialOutput, RequiredSignature, TxReceipt, UTXO}; +use crate::transaction::{ChangeOutput, FinalTransaction, PartialOutput, RequiredSignature}; +#[cfg(feature = "provider")] +use crate::transaction::{PartialInput, TxReceipt, UTXO}; use super::error::SignerError; @@ -53,6 +60,7 @@ pub trait SignerTrait { program: &dyn ProgramTrait, input_index: usize, network: &SimplicityNetwork, + derivation_path: Option<&DerivationPath>, ) -> Result; /// Generates an ECDSA signature to spend a standard transaction input. @@ -63,15 +71,21 @@ pub trait SignerTrait { &self, pst: &PartiallySignedTransaction, input_index: usize, + derivation_path: Option<&DerivationPath>, ) -> Result<(PublicKey, ecdsa::Signature), SignerError>; } /// Core interface responsible for managing keys, interfacing with the blockchain provider, /// assembling descriptors, estimating fees, and finalizing/signing transactions. +/// +/// Without the `provider` feature the signer has no blockchain access: it can assemble, +/// blind, sign and finalize a transaction it is handed, but it cannot discover UTXOs, +/// look up a fee rate, or broadcast. pub struct Signer { mnemonic: Mnemonic, xprv: Xpriv, - provider: Box, + #[cfg(feature = "provider")] + provider: Option>, network: SimplicityNetwork, secp: Secp256k1, } @@ -83,11 +97,12 @@ impl SignerTrait for Signer { program: &dyn ProgramTrait, input_index: usize, network: &SimplicityNetwork, + derivation_path: Option<&DerivationPath>, ) -> Result { let env = program.get_env(pst, input_index, network)?; let msg = Message::from_digest(env.c_tx_env().sighash_all().to_byte_array()); - let private_key = self.get_private_key(); + let private_key = self.get_private_key_at(derivation_path); let keypair = Keypair::from_secret_key(&self.secp, &private_key.inner); Ok(self.secp.sign_schnorr(&msg, &keypair)) @@ -97,6 +112,7 @@ impl SignerTrait for Signer { &self, pst: &PartiallySignedTransaction, input_index: usize, + derivation_path: Option<&DerivationPath>, ) -> Result<(PublicKey, ecdsa::Signature), SignerError> { let tx = pst.extract_tx()?; @@ -107,7 +123,7 @@ impl SignerTrait for Signer { .sighash_msg(input_index, &mut sighash_cache, None, genesis_hash)? .to_secp_msg(); - let private_key = self.get_private_key(); + let private_key = self.get_private_key_at(derivation_path); let public_key = private_key.public_key(&self.secp); let signature = self.secp.sign_ecdsa_low_r(&message, &private_key.inner); @@ -127,9 +143,25 @@ impl Signer { /// /// # Panics /// Panics if the mnemonic fails to parse, or if deriving the master private key fails. + #[cfg(feature = "provider")] #[must_use] pub fn new(mnemonic: &str, provider: Box) -> Self { let network = *provider.get_network(); + let mut signer = Self::from_mnemonic(mnemonic, network); + + signer.provider = Some(provider); + + signer + } + + /// Creates a `Signer` from a mnemonic and an explicit network, with no blockchain access. + /// + /// This is the constructor a host with its own networking and its own key custody should use. + /// + /// # Panics + /// Panics if the mnemonic fails to parse, or if deriving the master private key fails. + #[must_use] + pub fn from_mnemonic(mnemonic: &str, network: SimplicityNetwork) -> Self { let secp = Secp256k1::new(); let mnemonic: Mnemonic = mnemonic .parse() @@ -142,7 +174,8 @@ impl Signer { Self { mnemonic, xprv, - provider, + #[cfg(feature = "provider")] + provider: None, network, secp, } @@ -153,6 +186,7 @@ impl Signer { /// # Errors /// Returns a `SignerError` if compiling the inputs fails, there are insufficient funds/fees, or broadcast is rejected. // TODO: add an ability to send arbitrary assets + #[cfg(feature = "provider")] pub fn send(&self, to: Script, amount: u64) -> Result, SignerError> { let mut ft = FinalTransaction::new(); @@ -160,23 +194,25 @@ impl Signer { let (tx, _fee) = self.finalize(&ft)?; - Ok(self.provider.broadcast_transaction(&tx)?) + Ok(self.get_provider()?.broadcast_transaction(&tx)?) } /// Evaluates, funds, and broadcasts an already assembled `FinalTransaction`. /// /// # Errors /// Returns a `SignerError` if finalizing the payload fails or if the network rejects the broadcast. + #[cfg(feature = "provider")] pub fn broadcast(&self, tx: &FinalTransaction) -> Result, SignerError> { let (tx, _fee) = self.finalize(tx)?; - Ok(self.provider.broadcast_transaction(&tx)?) + Ok(self.get_provider()?.broadcast_transaction(&tx)?) } /// Evaluates the input components of a `FinalTransaction`, iteratively selecting available wallet UTXOs to cover outputs and estimated fees. /// /// # Errors /// Returns a `SignerError` if the wallet contains insufficient funds to satisfy output values and target fee rates. + #[cfg(feature = "provider")] pub fn finalize(&self, tx: &FinalTransaction) -> Result<(Transaction, u64), SignerError> { let mut signer_utxos = self.get_utxos_asset(self.network.policy_asset())?; let mut set = HashSet::new(); @@ -195,7 +231,7 @@ impl Signer { let mut fee_tx = tx.clone(); let mut curr_fee = MIN_FEE; - let fee_rate = self.provider.fetch_fee_rate(1)?; + let fee_rate = self.get_provider()?.fetch_fee_rate(1)?; for utxo in signer_utxos { let policy_amount_delta = fee_tx.calculate_fee_delta(&self.network); @@ -234,20 +270,14 @@ impl Signer { /// /// # Errors /// Returns a `SignerError` if the assembled inputs do not meet dust limits or fail to cover the - /// dynamically estimated required fee. - pub fn finalize_strict( - &self, - tx: &FinalTransaction, - target_blocks: u32, - ) -> Result<(Transaction, u64), SignerError> { + /// dynamically estimated required fee. + pub fn finalize_strict(&self, tx: &FinalTransaction, fee_rate: f32) -> Result<(Transaction, u64), SignerError> { let policy_amount_delta = tx.calculate_fee_delta(&self.network); if policy_amount_delta < MIN_FEE.cast_signed() { return Err(SignerError::DustAmount(policy_amount_delta)); } - let fee_rate = self.provider.fetch_fee_rate(target_blocks)?; - // policy_amount_delta will be > 0 match self.estimate_tx(tx.clone(), fee_rate, policy_amount_delta.cast_unsigned())? { Estimate::Success(tx, fee) => { @@ -259,9 +289,12 @@ impl Signer { } /// Returns a reference to the active configured network provider. - #[must_use] - pub fn get_provider(&self) -> &dyn ProviderTrait { - self.provider.as_ref() + /// + /// # Errors + /// Returns `ProviderUnavailable` when the signer was built without one. + #[cfg(feature = "provider")] + pub fn get_provider(&self) -> Result<&dyn ProviderTrait, SignerError> { + self.provider.as_deref().ok_or(SignerError::ProviderUnavailable) } /// Returns the confidential elements address matching the local wallet logic. @@ -275,7 +308,7 @@ impl Signer { .map_err(|e| SignerError::Slip77Descriptor(e.to_string())) .unwrap(); - // confidential descriptor doesn't support multipath + // Confidential descriptor doesn't support multipath descriptor.descriptor = descriptor.descriptor.into_single_descriptors().unwrap()[0].clone(); descriptor @@ -306,6 +339,7 @@ impl Signer { /// /// # Errors /// Returns a `SignerError` if querying the network or unblinding operations fail. + #[cfg(feature = "provider")] pub fn get_utxos(&self) -> Result, SignerError> { self.get_utxos_filter(&|_| true, &|_| true) } @@ -314,6 +348,7 @@ impl Signer { /// /// # Errors /// Returns a `SignerError` if network interaction or confidential output decryption fails. + #[cfg(feature = "provider")] pub fn get_utxos_asset(&self, asset: AssetId) -> Result, SignerError> { self.get_utxos_filter(&|utxo| utxo.asset() == asset, &|utxo| utxo.asset() == asset) } @@ -323,6 +358,7 @@ impl Signer { /// # Errors /// Returns a `SignerError` if querying the network fails. // TODO: can this be optimized to not populate TxOuts that are filtered out? + #[cfg(feature = "provider")] pub fn get_utxos_txid(&self, txid: Txid) -> Result, SignerError> { self.get_utxos_filter(&|utxo| utxo.outpoint.txid == txid, &|utxo| utxo.outpoint.txid == txid) } @@ -332,13 +368,16 @@ impl Signer { /// /// # Errors /// Returns a `SignerError` if retrieving remote outputs or executing confidential node unblinding throws an error. + #[cfg(feature = "provider")] pub fn get_utxos_filter( &self, explicit_filter: &dyn Fn(&UTXO) -> bool, confidential_filter: &dyn Fn(&UTXO) -> bool, ) -> Result, SignerError> { // fetch explicit and confidential utxos - let mut all_utxos = self.provider.fetch_address_utxos(&self.get_confidential_address())?; + let mut all_utxos = self + .get_provider()? + .fetch_address_utxos(&self.get_confidential_address())?; // filter out only confidential utxos and unblind them let mut confidential_utxos = self.unblind( @@ -387,15 +426,31 @@ impl Signer { /// Panics if the master private key or derivation path cannot be derived. #[must_use] pub fn get_private_key(&self) -> PrivateKey { + self.get_private_key_at(None) + } + + /// Derives the signing key at a path relative to the account path. `None` defaults to `0/0`. + /// + /// # Panics + /// Panics if the master private key or derivation path cannot be derived. + #[must_use] + pub fn get_private_key_at(&self, relative: Option<&DerivationPath>) -> PrivateKey { let master_xprv = self.master_xpriv().unwrap(); let full_path = self.get_derivation_path().unwrap(); - let derived = full_path.extend( - DerivationPath::from_str("0/0") - .map_err(|e| SignerError::DerivationPath(e.to_string())) - .unwrap(), - ); + let default_path; + let relative = match relative { + Some(path) => path, + None => { + default_path = DerivationPath::from_str("0/0") + .map_err(|e| SignerError::DerivationPath(e.to_string())) + .unwrap(); + + &default_path + } + }; + let derived = full_path.extend(relative); let ext_derived = master_xprv.derive_priv(&self.secp, &derived).unwrap(); PrivateKey::new(ext_derived.private_key, self.network) @@ -418,6 +473,7 @@ impl Signer { PrivateKey::new(blinding_key, self.network) } + #[cfg(feature = "provider")] fn unblind(&self, utxos: Vec) -> Result, SignerError> { let mut unblinded: Vec = Vec::new(); @@ -437,18 +493,23 @@ impl Signer { &self, mut fee_tx: FinalTransaction, fee_rate: f32, - available_delta: u64, + available_delta: u64 ) -> Result { - // estimate the tx fee with the change - // use this wpkh address as a change script - fee_tx.add_output( - PartialOutput::new( - self.get_address().script_pubkey(), - PLACEHOLDER_FEE, - self.network.policy_asset(), - ) - .with_blinding_key(self.get_blinding_public_key()), - ); + // Estimate the tx fee with the change. The caller supplies the change target + let change = match fee_tx.change() { + Some(target) => target.clone(), + None => { + ChangeOutput::new(self.get_address().script_pubkey()).with_blinding_key(self.get_blinding_public_key()) + } + }; + + let mut change_output = PartialOutput::new(change.script_pubkey, PLACEHOLDER_FEE, self.network.policy_asset()); + + if let Some(blinding_key) = change.blinding_key { + change_output = change_output.with_blinding_key(blinding_key); + } + + fee_tx.add_output(change_output); fee_tx.add_output(PartialOutput::new( Script::new(), @@ -471,7 +532,7 @@ impl Signer { return Ok(Estimate::Success(final_tx, fee)); } - // not enough funds, so we need to estimate without the change + // Not enough funds, so we need to estimate without the change // TODO: if a UTXO being spent is confidential + there are no // confidential outputs + there is no change, this will fail // with `RPC error -26: bad-txns-in-ne-out, value in != value out`. @@ -487,10 +548,10 @@ impl Signer { let outputs = fee_tx.outputs_mut(); - // change the fee output amount + // Change the fee output amount outputs[outputs.len() - 1].amount = available_delta; - // finalize the tx with fee and without the change + // Finalize the tx with fee and without the change let final_tx = self.sign_tx(&fee_tx)?; Ok(Estimate::Success(final_tx, fee)) @@ -522,21 +583,22 @@ impl Signer { witness_name, sig_path, index, + input_i.partial_input.derivation_path.as_ref(), )?), // just build the witness None => Ok(program_input.witness.build_witness()), }; - let pruned_witness = - program_input - .program - .finalize(&pst, &signed_witness.unwrap(), index, &self.network)?; + let pruned_witness = program_input + .program + .finalize(&pst, &signed_witness.unwrap(), index, &self.network) + .map_err(|source| SignerError::CovenantExecution { index, source })?; pst.inputs_mut()[index].final_script_witness = Some(pruned_witness); } else { // we need to sign the UTXO as is // TODO: do we always sign? - let signed_witness = self.sign_input(&pst, index)?; + let signed_witness = self.sign_input(&pst, index, input_i.partial_input.derivation_path.as_ref())?; let raw_sig = elementssig_to_rawsig(&(signed_witness.1, EcdsaSighashType::All)); pst.inputs_mut()[index].final_script_witness = Some(vec![raw_sig, signed_witness.0.to_bytes()]); @@ -554,8 +616,9 @@ impl Signer { witness_name: &str, sig_path: &[String], index: usize, + derivation_path: Option<&DerivationPath>, ) -> Result { - let signature = self.sign_program(pst, program, index, &self.network)?; + let signature = self.sign_program(pst, program, index, &self.network, derivation_path)?; // inject the signature into the wtns name directly if the path is not provided let sig_val = if sig_path.is_empty() { @@ -668,7 +731,11 @@ mod tests { let address = signer.get_address(); let pubkey = signer.get_ecdsa_public_key(); - let derived_addr = Address::p2wpkh(&pubkey, None, signer.get_provider().get_network().address_params()); + let derived_addr = Address::p2wpkh( + &pubkey, + None, + signer.get_provider().unwrap().get_network().address_params(), + ); assert_eq!(derived_addr.to_string(), address.to_string()); } diff --git a/crates/sdk/src/signer/error.rs b/crates/sdk/src/signer/error.rs index d41c7076..410cd42c 100644 --- a/crates/sdk/src/signer/error.rs +++ b/crates/sdk/src/signer/error.rs @@ -8,10 +8,24 @@ pub enum SignerError { #[error(transparent)] Program(#[from] ProgramError), + /// Error indicating that a Simplicity program failed to satisfy, prune or execute. + #[error("Covenant input {index} did not execute: {source}")] + CovenantExecution { + /// The index of the input whose program failed. + index: usize, + /// The underlying program failure. + source: ProgramError, + }, + /// Errors originating from provider network interactions. #[error(transparent)] Provider(#[from] ProviderError), + /// Error indicating a provider-backed operation was requested on a signer built without one. + #[cfg(feature = "provider")] + #[error("This signer was constructed without a provider")] + ProviderUnavailable, + /// Errors encountered when attempting to inject or wrap witness fields. #[error(transparent)] WtnsInjectError(#[from] WtnsWrappingError), diff --git a/crates/sdk/src/transaction/change_output.rs b/crates/sdk/src/transaction/change_output.rs new file mode 100644 index 00000000..90147fc7 --- /dev/null +++ b/crates/sdk/src/transaction/change_output.rs @@ -0,0 +1,33 @@ +use elements_miniscript::bitcoin::PublicKey; + +use simplicityhl::elements::Script; + +/// Where a transaction's change should go, supplied by the caller. +/// +/// Without this the signer would send change to the single address it derives internally. +#[derive(Debug, Clone)] +pub struct ChangeOutput { + /// The script the change output pays to. + pub script_pubkey: Script, + /// The blinding public key, when the change output is confidential. + pub blinding_key: Option, +} + +impl ChangeOutput { + /// Creates an explicit (unblinded) change target. + #[must_use] + pub fn new(script_pubkey: Script) -> Self { + Self { + script_pubkey, + blinding_key: None, + } + } + + /// Attaches a blinding public key, making the change output confidential. + #[must_use] + pub fn with_blinding_key(mut self, blinding_key: PublicKey) -> Self { + self.blinding_key = Some(blinding_key); + + self + } +} diff --git a/crates/sdk/src/transaction/final_transaction.rs b/crates/sdk/src/transaction/final_transaction.rs index 0742a51e..57e8091c 100644 --- a/crates/sdk/src/transaction/final_transaction.rs +++ b/crates/sdk/src/transaction/final_transaction.rs @@ -11,6 +11,7 @@ use simplicityhl::elements::{ use crate::provider::SimplicityNetwork; use crate::utils; +use super::change_output::ChangeOutput; use super::partial_input::{IssuanceInput, PartialInput, ProgramInput, RequiredSignature}; use super::partial_output::PartialOutput; @@ -143,6 +144,7 @@ impl FinalInput { pub struct FinalTransaction { inputs: Vec, outputs: Vec, + change: Option, } impl FinalTransaction { @@ -153,9 +155,22 @@ impl FinalTransaction { Self { inputs: Vec::new(), outputs: Vec::new(), + change: None, } } + /// Sets where this transaction's change should go. + /// + /// Left unset, the signer sends change to the single address it derives internally. + pub fn add_change(&mut self, change: ChangeOutput) { + self.change = Some(change); + } + + /// Drops the change target, returning to the signer's own address. + pub fn remove_change(&mut self) { + self.change = None; + } + /// Adds a new input to the transaction. /// /// # Panics @@ -262,6 +277,18 @@ impl FinalTransaction { None } + /// Where this transaction's change should go, when the caller said. + #[must_use] + pub fn change(&self) -> Option<&ChangeOutput> { + self.change.as_ref() + } + + /// Where this transaction's change should go, for a caller that needs to amend it. + #[must_use] + pub fn change_mut(&mut self) -> Option<&mut ChangeOutput> { + self.change.as_mut() + } + /// Provides a slice reference to the collection of `FinalInput` elements. #[must_use] pub fn inputs(&self) -> &[FinalInput] { diff --git a/crates/sdk/src/transaction/mod.rs b/crates/sdk/src/transaction/mod.rs index c4031daf..9b2e019a 100644 --- a/crates/sdk/src/transaction/mod.rs +++ b/crates/sdk/src/transaction/mod.rs @@ -1,14 +1,16 @@ +/// Represents outputs under construction before transaction finalization. +pub mod change_output; /// Represents a fully finalized target transaction schema ready for signing and broadcasting. pub mod final_transaction; /// Represents inputs under construction before transaction finalization. pub mod partial_input; -/// Represents outputs under construction before transaction finalization. -pub mod partial_output; +mod partial_output; /// Contains data representing the submission status of a broadcast transaction. pub mod tx_receipt; /// Common representation of unspent transaction outputs used as funding sources. pub mod utxo; +pub use change_output::ChangeOutput; pub use final_transaction::{FinalInput, FinalTransaction, IssuanceDetails}; pub use partial_input::{PartialInput, ProgramInput, RequiredSignature}; pub use partial_output::PartialOutput; diff --git a/crates/sdk/src/transaction/partial_input.rs b/crates/sdk/src/transaction/partial_input.rs index 88b6235c..b6e12eca 100644 --- a/crates/sdk/src/transaction/partial_input.rs +++ b/crates/sdk/src/transaction/partial_input.rs @@ -1,3 +1,5 @@ +use elements_miniscript::bitcoin::bip32::DerivationPath; + use simplicityhl::elements::confidential::{Asset, Value}; use simplicityhl::elements::pset::Input; use simplicityhl::elements::{AssetId, LockTime, OutPoint, Sequence, TxOut, TxOutSecrets, Txid}; @@ -55,6 +57,11 @@ pub struct PartialInput { /// Optional blinding secrets mapping values and asset states into confidential outputs. /// Note: if UTXO is confidential, `secrets` are `Some`. pub secrets: Option, + /// Derivation path of the key that can spend this input, relative to the account path. + /// + /// `None` means the signer's default path. A wallet whose UTXOs sit across many + /// derivation indices must set this per input, or the signer will sign with the wrong key. + pub derivation_path: Option, } /// Represents an input that runs a specific Simplicity program with an associated witness. @@ -110,9 +117,24 @@ impl PartialInput { amount, asset, secrets: utxo.secrets, + derivation_path: None, } } + /// Sets the derivation path, relative to the account path, of the key that spends this input. + /// + /// Relative means it is appended to `m/84h/{coin}h/0h`. + /// + /// ``` + /// let path = DerivationPath::from_str("0/3").expect("chain and index"); + /// ``` + #[must_use] + pub fn with_derivation_path(mut self, derivation_path: DerivationPath) -> Self { + self.derivation_path = Some(derivation_path); + + self + } + /// Sets a specific `Sequence` for the input. #[must_use] pub fn with_sequence(mut self, sequence: Sequence) -> Self { diff --git a/crates/simplex/Cargo.toml b/crates/simplex/Cargo.toml index b29aca08..681a3425 100644 --- a/crates/simplex/Cargo.toml +++ b/crates/simplex/Cargo.toml @@ -20,7 +20,7 @@ workspace = true [dependencies] smplx-macros = { workspace = true } smplx-test = { workspace = true } -smplx-sdk = { workspace = true } +smplx-sdk = { workspace = true, features = ["provider"] } serde = { workspace = true } simplicityhl = { workspace = true, features = ["serde"] } diff --git a/crates/test/Cargo.toml b/crates/test/Cargo.toml index f3cec763..515f69c0 100644 --- a/crates/test/Cargo.toml +++ b/crates/test/Cargo.toml @@ -11,7 +11,7 @@ readme = "../../README.md" workspace = true [dependencies] -smplx-sdk = { workspace = true } +smplx-sdk = { workspace = true, features = ["provider"] } smplx-regtest = { workspace = true } thiserror = { workspace = true } diff --git a/crates/test/src/context.rs b/crates/test/src/context.rs index 92ce061b..ceba5aca 100644 --- a/crates/test/src/context.rs +++ b/crates/test/src/context.rs @@ -70,16 +70,22 @@ impl TestContext { &self.signer } + /// # Panics + /// Panics when the signer was built without a provider, which a test context never is. pub fn get_default_provider(&self) -> &dyn ProviderTrait { - self.signer.get_provider() + self.signer + .get_provider() + .expect("a test context always has a provider") } pub fn get_config(&self) -> &TestConfig { &self.config } + /// # Panics + /// Panics when the signer was built without a provider, which a test context never is. pub fn get_network(&self) -> &SimplicityNetwork { - self.signer.get_provider().get_network() + self.get_default_provider().get_network() } pub fn get_network_utils(&self) -> NetworkUtils { diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml new file mode 100644 index 00000000..b2a144bb --- /dev/null +++ b/crates/wasm/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "smplx-wasm" +version = "0.0.9" +description = "WebAssembly bindings for the Simplex sdk" +license.workspace = true +edition.workspace = true +repository = "https://github.com/BlockstreamResearch/smplx" +readme = "../../README.md" + +[lints] +workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +smplx-sdk = { workspace = true } +simplicityhl = { workspace = true } +hex = { workspace = true } + +# Pinned exactly: wasm-bindgen refuses to run when the CLI and the crate disagree on the +# bindgen schema, so this must match the `wasm-bindgen` binary used to generate bindings. +wasm-bindgen = { version = "=0.2.100" } +elements-miniscript = { version = "0.4" } +serde_json = { version = "1.0" } diff --git a/crates/wasm/build.sh b/crates/wasm/build.sh new file mode 100755 index 00000000..6dd16194 --- /dev/null +++ b/crates/wasm/build.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Builds the browser-loadable Simplex SDK package into crates/wasm/pkg. +# +# Uses wasm-pack instead of wasm-bindgen directly. +# +# Usage: crates/wasm/build.sh [bundler|nodejs|web] (default: bundler) + +set -euo pipefail + +TARGET_KIND="${1:-bundler}" +CRATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORKSPACE_DIR="$(cd "${CRATE_DIR}/../.." && pwd)" + +# A wasm-capable C compiler is required. Apple's system clang has no WebAssembly +# backend, and without this the build fails inside secp256k1-sys and simplicity-sys +# with "unable to create target", which points at the crates and misleads. +if [ -z "${CC_wasm32_unknown_unknown:-}" ]; then + for candidate in /opt/homebrew/opt/llvm/bin/clang /usr/local/opt/llvm/bin/clang /usr/bin/clang; do + if [ -x "${candidate}" ] && "${candidate}" -print-targets 2>/dev/null | grep -q wasm32; then + export CC_wasm32_unknown_unknown="${candidate}" + + candidate_ar="$(dirname "${candidate}")/llvm-ar" + + if [ ! -x "${candidate_ar}" ]; then + candidate_ar="$(command -v llvm-ar || true)" + fi + + if [ -z "${candidate_ar}" ]; then + candidate_ar="$(ls -1 "$(dirname "${candidate}")"/llvm-ar-* 2>/dev/null | sort -V | tail -1 || true)" + fi + + if [ -z "${candidate_ar}" ]; then + echo "error: found ${candidate} but no llvm-ar beside it or on PATH." >&2 + echo " Install LLVM's binutils, or set AR_wasm32_unknown_unknown yourself." >&2 + exit 1 + fi + + export AR_wasm32_unknown_unknown="${candidate_ar}" + break + fi + done +fi + +if [ -z "${CC_wasm32_unknown_unknown:-}" ]; then + echo "error: no C compiler with a WebAssembly backend was found." >&2 + echo " Install LLVM (brew install llvm) and set CC_wasm32_unknown_unknown" >&2 + echo " and AR_wasm32_unknown_unknown to its clang and llvm-ar." >&2 + exit 1 +fi + +if ! command -v wasm-pack >/dev/null 2>&1; then + echo "error: wasm-pack is not installed." >&2 + echo " Install it with: cargo install wasm-pack" >&2 + exit 1 +fi + +cd "${WORKSPACE_DIR}" + +wasm-pack build crates/wasm --target "${TARGET_KIND}" --release --out-dir pkg + +echo +echo "Built ${CRATE_DIR}/pkg (${TARGET_KIND}):" +ls -l "${CRATE_DIR}/pkg" diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs new file mode 100644 index 00000000..2d2d6225 --- /dev/null +++ b/crates/wasm/src/lib.rs @@ -0,0 +1,564 @@ +#![warn(clippy::all, clippy::pedantic, missing_docs)] +//! WebAssembly bindings for the Simplex SDK. +//! +//! This crate exists so the SDK itself stays free of `wasm-bindgen` annotations and the +//! binding surface can be exactly what a host needs rather than the whole SDK. It follows +//! the arrangement `lwk_wasm` already uses. +//! +//! The surface is deliberately small and grows with the host that consumes it. What is +//! here is enough to establish that the module loads, that compilation runs inside the +//! browser, and that a value derived from a compiled contract crosses the boundary. + +use std::str::FromStr; +use std::sync::Arc; + +use elements_miniscript::bitcoin::PublicKey; + +use simplicityhl::elements; +use simplicityhl::elements::{AssetId, OutPoint, Script, Sequence, TxOut, Txid}; +use simplicityhl::{Arguments, WitnessValues}; + +use smplx_sdk::program::{ArgumentsTrait, Program, WitnessTrait}; +use smplx_sdk::provider::SimplicityNetwork; +use smplx_sdk::signer::Signer; +use smplx_sdk::transaction::{ + ChangeOutput, FinalTransaction, PartialInput, PartialOutput, ProgramInput, RequiredSignature, UTXO, +}; + +use wasm_bindgen::prelude::*; + +/// Resolves a network name to the SDK's network enum. +fn network_from_str(network: &str) -> Result { + match network { + "liquid" => Ok(SimplicityNetwork::Liquid), + "liquid-testnet" | "liquidtestnet" => Ok(SimplicityNetwork::LiquidTestnet), + "elements-regtest" | "elementsregtest" | "regtest" => Ok(SimplicityNetwork::default_regtest()), + other => Err(JsError::new(&format!("Unknown network: {other}"))), + } +} + +/// Compile-time parameters for a contract, resolved before construction. +#[derive(Clone)] +struct FixedArguments(Arguments); + +impl ArgumentsTrait for FixedArguments { + fn build_arguments(&self) -> Arguments { + self.0.clone() + } +} + +/// Witness values for a covenant input, resolved before the transaction is assembled. +/// +/// Held as parsed `WitnessValues` so a malformed set is rejected when the caller supplies +/// it rather than in the middle of signing. +#[derive(Clone)] +struct FixedWitness(WitnessValues); + +impl WitnessTrait for FixedWitness { + fn build_witness(&self) -> WitnessValues { + self.0.clone() + } +} + +/// A compiled SimplicityHL contract. +#[wasm_bindgen] +pub struct Contract { + program: Program, +} + +#[wasm_bindgen] +impl Contract { + /// Creates a contract from SimplicityHL source text delivered at runtime. + /// + /// `argumentsJson` carries the contract's compile-time parameters. + /// + /// Shape: `{"NAME": {"value": "0x…", "type": "Pubkey"}}`. + /// Pass `null` for a contract that declares no parameters. + /// + /// `extraLeavesJson` is a JSON array of hex strings, each an encoded taproot + /// leaf payload appended to the tree in declaration order. + /// + /// # Errors + /// Returns an error if the arguments are not valid SimplicityHL argument JSON, or if the + /// extra leaves are not a JSON array of hex strings. + #[wasm_bindgen(constructor)] + pub fn new( + source: &str, + arguments_json: Option, + extra_leaves_json: Option, + include_debug_symbols: Option, + ) -> Result { + let arguments = match arguments_json.as_deref() { + Some(json) if !json.trim().is_empty() => serde_json::from_str::(json) + .map_err(|e| JsError::new(&format!("Invalid contract arguments: {e}")))?, + _ => Arguments::default(), + }; + + let mut program = Program::new(Arc::::from(source), &FixedArguments(arguments)); + + if let Some(include) = include_debug_symbols { + program = program.with_debug_symbols(include); + } + + if let Some(json) = extra_leaves_json.as_deref().filter(|json| !json.trim().is_empty()) { + let leaves: Vec = + serde_json::from_str(json).map_err(|e| JsError::new(&format!("Invalid extra leaves: {e}")))?; + + program = program.with_storage_capacity(leaves.len()); + + for (index, leaf) in leaves.iter().enumerate() { + let bytes = hex::decode(leaf.strip_prefix("0x").unwrap_or(leaf)) + .map_err(|e| JsError::new(&format!("Extra leaf {index} is not hex: {e}")))?; + + program.set_storage_at(index, bytes); + } + } + + Ok(Self { program }) + } + + /// Compiles the contract and returns its Commitment Merkle Root as lowercase hex. + #[wasm_bindgen(js_name = commitmentMerkleRoot)] + pub fn commitment_merkle_root(&self) -> String { + let cmr = self.program.get_cmr(); + + hex::encode(cmr) + } + + /// Compiles the contract and returns the scriptPubKey its funds sit behind, as hex. + /// + /// # Errors + /// Returns an error if the network name is unknown or the source fails to compile. + #[wasm_bindgen(js_name = scriptPubKeyHex)] + pub fn script_pubkey_hex(&self, network: &str) -> Result { + let network = network_from_str(network)?; + + Ok(hex::encode(self.program.get_script_pubkey(&network).as_bytes())) + } + + /// Compiles the contract and returns the taproot address its funds would sit at. + /// + /// # Errors + /// Returns an error if the network name is unknown or the source fails to compile. + #[wasm_bindgen(js_name = contractAddress)] + pub fn contract_address(&self, network: &str) -> Result { + let network = network_from_str(network)?; + + Ok(self.program.get_tr_address(&network).to_string()) + } +} + +/// The wallet's signer that understands how to work with Simplicity. +#[wasm_bindgen] +pub struct WalletSigner { + signer: Signer, + network: SimplicityNetwork, +} + +#[wasm_bindgen] +impl WalletSigner { + /// Creates a signer from an account mnemonic. + /// + /// # Errors + /// Returns an error if the network name is unknown. + #[wasm_bindgen(constructor)] + pub fn new(mnemonic: &str, network: &str) -> Result { + let network = network_from_str(network)?; + + Ok(Self { + signer: Signer::from_mnemonic(mnemonic, network), + network, + }) + } + + /// The unblinded address of the signer's own key. + #[wasm_bindgen(js_name = address)] + #[must_use] + pub fn address(&self) -> String { + let _ = &self.network; + + self.signer.get_address().to_string() + } + + /// The confidential address of the signer's own key. + #[wasm_bindgen(js_name = confidentialAddress)] + #[must_use] + pub fn confidential_address(&self) -> String { + self.signer.get_confidential_address().to_string() + } + + /// The x-only public key used for Schnorr and taproot, as lowercase hex. + #[wasm_bindgen(js_name = schnorrPublicKey)] + #[must_use] + pub fn schnorr_public_key(&self) -> String { + hex::encode(self.signer.get_schnorr_public_key().serialize()) + } + + /// The compressed public key used for ordinary wallet inputs, as lowercase hex. + #[wasm_bindgen(js_name = ecdsaPublicKey)] + #[must_use] + pub fn ecdsa_public_key(&self) -> String { + hex::encode(self.signer.get_ecdsa_public_key().to_bytes()) + } + + /// The scriptPubKey of the signer's own address, as lowercase hex. + /// + /// This is what a wallet output pays to, so it is what the caller encodes into the + /// `TxOut` of an input it wants signed, and what it passes as the change script. + #[wasm_bindgen(js_name = scriptPubKeyHex)] + #[must_use] + pub fn script_pubkey_hex(&self) -> String { + hex::encode(self.signer.get_address().script_pubkey().as_bytes()) + } + + /// The blinding public key, as lowercase hex. + #[wasm_bindgen(js_name = blindingPublicKey)] + #[must_use] + pub fn blinding_public_key(&self) -> String { + hex::encode(self.signer.get_blinding_public_key().to_bytes()) + } + + /// Blinds, signs and finalizes an assembled transaction. + /// + /// # Errors + /// Returns an error if the transaction cannot be balanced, blinded, signed or finalised. + #[wasm_bindgen(js_name = finalizeTransaction)] + pub fn finalize_transaction( + &self, + builder: &TransactionBuilder, + fee_rate: f32, + ) -> Result { + let (transaction, fee_sats) = self + .signer + .finalize_strict(builder.inner(), fee_rate) + .map_err(|e| JsError::new(&format!("Could not finalise the transaction: {e}")))?; + + Ok(SignedTransaction { + fee_sats, + hex: elements::encode::serialize_hex(&transaction), + txid: transaction.txid().to_string(), + }) + } +} + +/// A transaction under construction. +/// +/// Inputs cross as an outpoint plus the raw `TxOut` they spend. +/// Unblinding is done on the Signer side. +/// +/// Coin selection is the caller's. +/// This assembles exactly what it is given and adds only the change and fee outputs. +#[wasm_bindgen] +pub struct TransactionBuilder { + transaction: FinalTransaction, +} + +#[wasm_bindgen] +impl TransactionBuilder { + /// Starts an empty transaction. + #[wasm_bindgen(constructor)] + #[must_use] + pub fn new() -> Self { + Self { + transaction: FinalTransaction::new(), + } + } + + /// Sets where this transaction's change should go. + /// + /// Left unset, change returns to the signer's own derived address. + /// + /// # Errors + /// Returns an error if the script or the blinding key cannot be parsed. + #[wasm_bindgen(js_name = addChange)] + pub fn add_change(&mut self, script_pubkey_hex: &str, blinding_key_hex: Option) -> Result<(), JsError> { + let script = Script::from( + hex::decode(script_pubkey_hex).map_err(|e| JsError::new(&format!("Invalid change script: {e}")))?, + ); + + let mut change = ChangeOutput::new(script); + + if let Some(blinding_key) = blinding_key_hex.as_deref() { + let key = PublicKey::from_str(blinding_key) + .map_err(|e| JsError::new(&format!("Invalid change blinding key: {e}")))?; + + change = change.with_blinding_key(key); + } + + self.transaction.add_change(change); + + Ok(()) + } + + /// Drops the change target, returning to the signer's own address. + #[wasm_bindgen(js_name = removeChange)] + pub fn remove_change(&mut self) { + self.transaction.remove_change(); + } + + /// Adds an ordinary wallet input, spending the output at `txid:vout`. + /// + /// `tx_out_hex` is the consensus encoding of the output being spent, which is what the + /// wallet already has from its own snapshot or a chain read. + /// + /// # Errors + /// Returns an error if the txid or the encoded output cannot be parsed. + #[wasm_bindgen(js_name = addWalletInput)] + pub fn add_wallet_input( + &mut self, + txid: &str, + vout: u32, + tx_out_hex: &str, + sequence: Option, + ) -> Result<(), JsError> { + let outpoint = OutPoint { + txid: Txid::from_str(txid).map_err(|e| JsError::new(&format!("Invalid txid: {e}")))?, + vout, + }; + + let bytes = hex::decode(tx_out_hex).map_err(|e| JsError::new(&format!("Invalid output encoding: {e}")))?; + let txout: TxOut = + elements::encode::deserialize(&bytes).map_err(|e| JsError::new(&format!("Invalid output: {e}")))?; + + self.transaction.add_input( + Self::with_sequence( + PartialInput::new(UTXO { + outpoint, + secrets: None, + txout, + }), + sequence, + ), + RequiredSignature::NativeEcdsa, + ); + + Ok(()) + } + + /// Adds a covenant input: an output locked by a Simplicity program, spent by satisfying it. + /// + /// `witness_json` carries the witness values in SimplicityHL's own `.wit` shape. + /// Passing `null` leaves them unset. + /// + /// `signature_witness` names the witness the signer must fill with a Schnorr signature + /// over this transaction. + /// Leaving this `null` says the program needs no signature. + /// + /// # Errors + /// Returns an error if the txid, the encoded output, the arguments or the witness cannot be parsed. + #[wasm_bindgen(js_name = addContractInput)] + pub fn add_contract_input( + &mut self, + txid: &str, + vout: u32, + tx_out_hex: &str, + source: &str, + arguments_json: Option, + witness_json: Option, + signature_witness: Option, + sequence: Option, + ) -> Result<(), JsError> { + let outpoint = OutPoint { + txid: Txid::from_str(txid).map_err(|e| JsError::new(&format!("Invalid txid: {e}")))?, + vout, + }; + + let bytes = hex::decode(tx_out_hex).map_err(|e| JsError::new(&format!("Invalid output encoding: {e}")))?; + let txout: TxOut = + elements::encode::deserialize(&bytes).map_err(|e| JsError::new(&format!("Invalid output: {e}")))?; + + let arguments = match arguments_json.as_deref() { + Some(json) if !json.trim().is_empty() => serde_json::from_str::(json) + .map_err(|e| JsError::new(&format!("Invalid contract arguments: {e}")))?, + _ => Arguments::default(), + }; + + let witness = match witness_json.as_deref() { + Some(json) if !json.trim().is_empty() => serde_json::from_str::(json) + .map_err(|e| JsError::new(&format!("Invalid witness values: {e}")))?, + _ => WitnessValues::default(), + }; + + let program = Program::new(Arc::::from(source), &FixedArguments(arguments)); + + self.transaction.add_program_input( + Self::with_sequence( + PartialInput::new(UTXO { + outpoint, + secrets: None, + txout, + }), + sequence, + ), + ProgramInput { + program: Box::new(program), + witness: Box::new(FixedWitness(witness)), + }, + Self::required_signature(signature_witness.as_deref())?, + ); + + Ok(()) + } + + /// Adds an output paying `amount_sats` of `asset_hex` to `script_pubkey_hex`. + /// + /// A blinding key makes the output confidential. Covenant and OP_RETURN outputs are always unblinded. + /// + /// # Errors + /// Returns an error if the script, asset id or blinding key cannot be parsed. + #[wasm_bindgen(js_name = addOutput)] + pub fn add_output( + &mut self, + script_pubkey_hex: &str, + amount_sats: u64, + asset_hex: &str, + blinding_key_hex: Option, + ) -> Result<(), JsError> { + let script = + Script::from(hex::decode(script_pubkey_hex).map_err(|e| JsError::new(&format!("Invalid script: {e}")))?); + let asset = AssetId::from_str(asset_hex).map_err(|e| JsError::new(&format!("Invalid asset id: {e}")))?; + + let mut output = PartialOutput::new(script, amount_sats, asset); + + if let Some(blinding_key) = blinding_key_hex.as_deref() { + let key = + PublicKey::from_str(blinding_key).map_err(|e| JsError::new(&format!("Invalid blinding key: {e}")))?; + + output = output.with_blinding_key(key); + } + + self.transaction.add_output(output); + + Ok(()) + } + + /// Runs the Simplicity program of one covenant input against this transaction. + /// + /// This is the dry-run: it satisfies the witness, prunes the branches the spend does not + /// take, and executes the result on a BitMachine. + /// + /// # Errors + /// Returns an error if the input is not a covenant input, or if the program fails to + /// satisfy, prune or execute. + #[wasm_bindgen(js_name = dryRunContractInput)] + pub fn dry_run_contract_input(&self, input_index: usize, network: &str) -> Result<(), JsError> { + let network = network_from_str(network)?; + let inputs = self.transaction.inputs(); + let input = inputs + .get(input_index) + .ok_or_else(|| JsError::new(&format!("There is no input at index {input_index}.")))?; + let program_input = input + .program_input + .as_ref() + .ok_or_else(|| JsError::new(&format!("Input {input_index} is not a covenant input.")))?; + + let (pst, _secrets) = self.transaction.extract_pst(); + + program_input + .program + .execute(&pst, &program_input.witness.build_witness(), input_index, &network) + .map_err(|e| JsError::new(&format!("Input {input_index} did not execute: {e}")))?; + + Ok(()) + } + + /// How many inputs and outputs this transaction currently carries. + #[wasm_bindgen(js_name = inputCount)] + #[must_use] + pub fn input_count(&self) -> usize { + self.transaction.n_inputs() + } + + /// How many outputs this transaction currently carries. + #[wasm_bindgen(js_name = outputCount)] + #[must_use] + pub fn output_count(&self) -> usize { + self.transaction.n_outputs() + } + + /// Which signature a covenant input needs, from the name the caller gave it. + /// + /// A witness can sit at the top of an input's witness set or inside a structure, and the + /// SDK distinguishes the two. The binding takes one string for both and splits it on `.`, + /// so `unlock` is the flat form and `Left.Right.1`. + /// + /// A name that is empty or only separators asks for no signature, which is what a covenant + /// that authenticates nothing wants. + fn required_signature(signature_witness: Option<&str>) -> Result { + let Some(raw) = signature_witness.map(str::trim).filter(|name| !name.is_empty()) else { + return Ok(RequiredSignature::None); + }; + + let mut segments = raw.split('.').map(str::trim).filter(|part| !part.is_empty()); + + let Some(name) = segments.next() else { + return Ok(RequiredSignature::None); + }; + + let path: Vec<&str> = segments.collect(); + + if path.is_empty() { + return Ok(RequiredSignature::Witness(name.to_string())); + } + + Ok(RequiredSignature::witness_with_path(name, path)) + } + + /// Applies a declared sequence to an input. + fn with_sequence(input: PartialInput, sequence: Option) -> PartialInput { + match sequence { + Some(value) => input.with_sequence(Sequence(value)), + None => input, + } + } + + /// The assembled transaction, for the signer in this crate. + fn inner(&self) -> &FinalTransaction { + &self.transaction + } +} + +impl Default for TransactionBuilder { + fn default() -> Self { + Self::new() + } +} + +/// A finished transaction and the fee it pays. +#[wasm_bindgen] +pub struct SignedTransaction { + fee_sats: u64, + hex: String, + txid: String, +} + +#[wasm_bindgen] +impl SignedTransaction { + /// The consensus-encoded transaction, ready to broadcast. + #[wasm_bindgen(getter)] + #[must_use] + pub fn hex(&self) -> String { + self.hex.clone() + } + + /// The transaction id it will have once broadcast. + #[wasm_bindgen(getter)] + #[must_use] + pub fn txid(&self) -> String { + self.txid.clone() + } + + /// The fee it pays, in satoshis. + #[wasm_bindgen(getter, js_name = feeSats)] + #[must_use] + pub fn fee_sats(&self) -> u64 { + self.fee_sats + } +} + +/// Returns the version of the SDK compiled into this module. +#[wasm_bindgen(js_name = sdkVersion)] +#[must_use] +pub fn sdk_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} diff --git a/examples/basic/tests/basic_test.rs b/examples/basic/tests/basic_test.rs index 2c53f8e7..9253626a 100644 --- a/examples/basic/tests/basic_test.rs +++ b/examples/basic/tests/basic_test.rs @@ -12,7 +12,7 @@ fn get_p2pk(context: &simplex::TestContext) -> (P2pkProgram, Script) { public_key: signer.get_schnorr_public_key().serialize(), }; - let p2pk = P2pkProgram::new(arguments); + let p2pk = P2pkProgram::new(&arguments); let p2pk_script = p2pk.get_script_pubkey(context.get_network()); (p2pk, p2pk_script) diff --git a/fixtures/simf/tapleaf_check.simf b/fixtures/simf/tapleaf_check.simf new file mode 100644 index 00000000..f7a59ea5 --- /dev/null +++ b/fixtures/simf/tapleaf_check.simf @@ -0,0 +1,7 @@ +fn check_tapleaf(passed_tapleaf_hash: u256) { + assert!(jet::eq_256(jet::tapleaf_hash(), passed_tapleaf_hash)) +} + +fn main() { + check_tapleaf(witness::PROGRAM_TAPLEAF_HASH); +} diff --git a/fixtures/tests/basic_test.rs b/fixtures/tests/basic_test.rs index bffd67f1..21c2e540 100644 --- a/fixtures/tests/basic_test.rs +++ b/fixtures/tests/basic_test.rs @@ -12,7 +12,7 @@ fn get_p2pk(context: &simplex::TestContext) -> (P2pkProgram, Script) { public_key: signer.get_schnorr_public_key().serialize(), }; - let p2pk = P2pkProgram::new(arguments); + let p2pk = P2pkProgram::new(&arguments); let p2pk_script = p2pk.get_script_pubkey(context.get_network()); (p2pk, p2pk_script) diff --git a/fixtures/tests/log_level.rs b/fixtures/tests/log_level.rs index 8c65197a..c575e2c9 100644 --- a/fixtures/tests/log_level.rs +++ b/fixtures/tests/log_level.rs @@ -7,7 +7,7 @@ fn setup_dummy(context: &simplex::TestContext) -> (DummyPanicProgram, simplex::s let signer = context.get_default_signer(); let dummy = - DummyPanicProgram::new(DummyPanicArguments::default()).with_taproot_pubkey(signer.get_schnorr_public_key()); + DummyPanicProgram::new(&DummyPanicArguments::default()).with_taproot_pubkey(signer.get_schnorr_public_key()); let script = dummy.get_script_pubkey(context.get_network()); diff --git a/fixtures/tests/multidep_test.rs b/fixtures/tests/multidep_test.rs index 52b86684..fe6bd860 100644 --- a/fixtures/tests/multidep_test.rs +++ b/fixtures/tests/multidep_test.rs @@ -8,7 +8,7 @@ use simplex_fixtures::artifacts::imports::multidep::derived_multidep::{MultidepA fn get_multidep(context: &simplex::TestContext) -> (MultidepProgram, Script) { let arguments = MultidepArguments { prev_hash: 5 }; - let p2pk = MultidepProgram::new(arguments); + let p2pk = MultidepProgram::new(&arguments); let p2pk_script = p2pk.get_script_pubkey(context.get_network()); (p2pk, p2pk_script) diff --git a/fixtures/tests/nested_sig.rs b/fixtures/tests/nested_sig.rs index d3f6e0b8..dcfb54a7 100644 --- a/fixtures/tests/nested_sig.rs +++ b/fixtures/tests/nested_sig.rs @@ -12,7 +12,7 @@ fn get_nested_sig(context: &simplex::TestContext) -> (NestedSigProgram, Script) public_key: signer.get_schnorr_public_key().serialize(), }; - let program = NestedSigProgram::new(arguments); + let program = NestedSigProgram::new(&arguments); let script = program.get_script_pubkey(context.get_network()); (program, script) diff --git a/fixtures/tests/tapleaf_test.rs b/fixtures/tests/tapleaf_test.rs new file mode 100644 index 00000000..dd67bbe8 --- /dev/null +++ b/fixtures/tests/tapleaf_test.rs @@ -0,0 +1,35 @@ +use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput, RequiredSignature}; + +use simplex_fixtures::artifacts::tapleaf_check::TapleafCheckProgram; +use simplex_fixtures::artifacts::tapleaf_check::derived_tapleaf_check::{TapleafCheckArguments, TapleafCheckWitness}; + +#[simplex::test] +fn program_tapleaf_test(context: simplex::TestContext) -> anyhow::Result<()> { + let signer = context.get_default_signer(); + let provider = context.get_default_provider(); + + let tapleaf_check = TapleafCheckProgram::new(&TapleafCheckArguments::default()); + let tapleaf_check_script = tapleaf_check.get_script_pubkey(context.get_network()); + + let tx_receipt = signer.send(tapleaf_check_script.clone(), 50)?; + tx_receipt.wait()?; + + let tapleaf_check_utxo = provider.fetch_scripthash_utxos(&tapleaf_check_script)?[0].clone(); + + let mut ft = FinalTransaction::new(); + + let witness = TapleafCheckWitness { + program_tapleaf_hash: tapleaf_check.get_tapleaf_hash(), + }; + + ft.add_program_input( + PartialInput::new(tapleaf_check_utxo), + ProgramInput::new(Box::new(tapleaf_check.as_ref().clone()), Box::new(witness.clone())), + RequiredSignature::None, + ); + + let tx_receipt = signer.broadcast(&ft)?; + tx_receipt.wait()?; + + Ok(()) +} From a6ecba1d904d09a41a5eb7a9151c43aa8a963728 Mon Sep 17 00:00:00 2001 From: Artem Chystiakov Date: Fri, 7 Aug 2026 19:37:39 +0300 Subject: [PATCH 2/2] revert visibility --- crates/sdk/src/transaction/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/sdk/src/transaction/mod.rs b/crates/sdk/src/transaction/mod.rs index 9b2e019a..e1726acf 100644 --- a/crates/sdk/src/transaction/mod.rs +++ b/crates/sdk/src/transaction/mod.rs @@ -4,7 +4,8 @@ pub mod change_output; pub mod final_transaction; /// Represents inputs under construction before transaction finalization. pub mod partial_input; -mod partial_output; +/// Represents outputs under construction before transaction finalization. +pub mod partial_output; /// Contains data representing the submission status of a broadcast transaction. pub mod tx_receipt; /// Common representation of unspent transaction outputs used as funding sources.