diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3cd7eff9..83adbcd8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,17 +67,23 @@ jobs: cargo build --target $TARGET --profile $RUST_PROFILE --bins mv $OUT_DIR/${{ env.RUST_PROFILE }}/simplex simplex - - name: Download nextest, elementsd and electrs + - name: Download nextest, simfmt, elementsd and electrs env: NEXTEST_VERSION: "0.9.137" ELEMENTSD_VERSION: "23.3.1" + SIMFMT_VERSION: "0.0.2" ELEMENTSD_TARGET: ${{ matrix.elementsd_target }} ELECTRS_TARGET: ${{ matrix.electrs_target }} NEXTEST_TARGET: ${{ matrix.nextest_target }} + SIMFMT_TARGET: ${{ matrix.target }} run: | + #!/usr/bin/env bash + set -eo pipefail + ELEMENTSD_FILENAME="elements-${ELEMENTSD_VERSION}-${ELEMENTSD_TARGET}.tar.gz" ELECTRS_FILENAME="electrs_${ELECTRS_TARGET}_esplora_027e38d3ebc2f85b28ae76f8f3448438ee4fc7b1_liquid.zip" NEXTEST_FILENAME="cargo-nextest-${NEXTEST_VERSION}-${NEXTEST_TARGET}.tar.gz" + SIMFMT_FILENAME="simfmt-v${SIMFMT_VERSION}-${SIMFMT_TARGET}.tar.gz" curl -Ls "https://github.com/ElementsProject/elements/releases/download/elements-${ELEMENTSD_VERSION}/${ELEMENTSD_FILENAME}" -o ${ELEMENTSD_FILENAME} tar -xzf ${ELEMENTSD_FILENAME} && rm ${ELEMENTSD_FILENAME} @@ -90,6 +96,9 @@ jobs: curl -Ls "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-${NEXTEST_VERSION}/${NEXTEST_FILENAME}" -o ${NEXTEST_FILENAME} tar -xzf ${NEXTEST_FILENAME} && rm ${NEXTEST_FILENAME} mv cargo-nextest smplx-nextest + + curl -fLsS "https://github.com/BlockstreamResearch/simfmt/releases/download/v${SIMFMT_VERSION}/${SIMFMT_FILENAME}" -o "${SIMFMT_FILENAME}" + tar -xzf "${SIMFMT_FILENAME}" && rm "${SIMFMT_FILENAME}" - name: Sign elementsd binary run: | @@ -106,6 +115,7 @@ jobs: elementsd electrs smplx-nextest + simfmt - name: Record attestation URL env: @@ -116,7 +126,7 @@ jobs: - name: Archive binaries env: ARCHIVE: simplex-${{ github.event.inputs.tag || github.ref_name }}-${{ matrix.archive }} - run: tar czf ${ARCHIVE} simplex elementsd electrs smplx-nextest + run: tar czf ${ARCHIVE} simplex elementsd electrs smplx-nextest simfmt - name: Upload build artifacts uses: actions/upload-artifact@v6 @@ -163,11 +173,14 @@ jobs: contents: read id-token: write concurrency: - group: publish-core-${{ github.ref }} + group: publish-core-${{ github.event.inputs.tag || github.ref }} cancel-in-progress: false steps: - name: Checkout uses: actions/checkout@v6 + with: + persist-credentials: false + ref: ${{ github.event.inputs.tag || github.ref }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable diff --git a/CHANGELOG.md b/CHANGELOG.md index 72ff316b..dfa0d98c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,68 @@ # Changelog +## [0.2.0] + +- Added `simplex fmt` to format SimplicityHL sources with `simfmt`, which `simplexup` now installs. +- Rewrote fuzz testing to a much more versatile and smart suite. +- Made every command find `Simplex.toml` in the current directory or any of its ancestors. + +### fuzz + +- Replaced the single-program fuzz engine with `Scenario`, a transaction template where each program `spends` inputs and `locks` outputs. +- Added `bind_arguments` and `bind_witness` to derive values from the case, and `requires_witness` for values the fuzzer also violates to check that the contract rejects them. +- Added `spec` to state when a case must be accepted, rejected or either, read from `FuzzCase`. +- Added transaction `Mutations` of amounts, assets, timelocks and layout, applied to the template's targeted inputs and outputs. +- Added lock and authorization smart checks that redirect locked outputs and replay signatures from a wrong key. +- Added `spending_path` and `storage_capacity` to the program builder, and `random_strategy` and `custom_strategy` to replace the guided default. +- Added a run summary of verdicts, rejections, spec predictions, violations, mutations and smart checks. +- Removed `FuzzableProgram`, `FuzzTransaction`, `ArgsStrategyBuilder`, `ProgramCheck` and `FuzzError`. + +### sdk + +- Replaced `RequiredSignature` arguments with `Option` for native inputs and `Option` for program inputs, built as `ProgramSignature::new(name).at_path(path).tagged(tag)`. +- Made `Signer` sign only inputs that request a native signature and refuse a key that doesn't own a P2WPKH input. +- Made `Signer` derive its keys and addresses once, and reuse one sighash cache per transaction. +- Reduced how often coin selection signs the transaction. +- Fixed coin selection: skip UTXOs listed twice, skip confidential UTXOs for an explicit change target, and keep blinded change that would reveal a blinded amount. +- Fixed fees: report the whole leftover as the fee when change is dropped, and fold explicit change below the 22 sat dust threshold into the fee. +- Made a set locktime enforced by default, by giving inputs without a sequence `ENABLE_LOCKTIME_NO_RBF`. +- Made per-input `sequence` and `locktime` optional, so an explicit `Sequence::MAX` or `LockTime::ZERO` is kept. +- Made `get_utxos` skip outputs the wallet can't unblind, instead of failing. +- Replaced panics with errors for unknown input amounts, amount overflows, invalid fee rates, duplicate inputs, empty issuances, unblinded reissuance tokens, inputs without a witness UTXO and invalid derivation paths. +- Fixed token-only issuance by encoding a zero asset amount as null. +- Added `FinalTransaction::sequence` and `locktime` getters. +- Added `Program::set_storage`, `get_arguments` and `MAX_STORAGE_SLOTS`, and capped storage at 128 slots. +- Made `SimplexProvider::broadcast_transaction` wait until Esplora indexes the transaction. +- Cached the program's CMR, witness types and taproot spend info. + +### build + +- Refused a dependency `src_dir` that resolves outside its package. +- Refused an `out_dir` that holds the project or its `src_dir`, in both `build` and `clean`. +- Refused source directories that aren't Rust identifiers, before writing any artifact. +- Warned about `.simf` files that don't parse, instead of skipping them silently. + +### install + +- Resolved tags and branches by their full ref. +- Made git dependency directory names unambiguous. +- Made installs atomic, and reported an incomplete dependency instead of treating it as installed. + +### init + +- Made `simplex init` optimize dependencies in dev builds, which speeds up tests. + +### wasm + +- Rejected issuances of zero, reissuance tokens with a zero blinding factor, derivation paths other than `m/n`, and more than 128 extra leaves. + +### simplexup + +- Verified binaries before installing them and refused versions whose install didn't complete. +- Verified the attestation's signer with `gh` when the GitHub CLI is installed and logged in. +- Installed `--commit` builds as their own version. +- Made `--use` switch every binary to the chosen version. + ## [0.1.1] - Fixed broken dependencies. diff --git a/Cargo.lock b/Cargo.lock index 9c5c8d92..306d85d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1598,7 +1598,7 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smplx-build" -version = "0.1.1" +version = "0.2.0" dependencies = [ "globwalk", "pathdiff", @@ -1616,7 +1616,7 @@ dependencies = [ [[package]] name = "smplx-cli" -version = "0.1.1" +version = "0.2.0" dependencies = [ "anyhow", "clap", @@ -1636,7 +1636,7 @@ dependencies = [ [[package]] name = "smplx-macros" -version = "0.1.1" +version = "0.2.0" dependencies = [ "smplx-build", "smplx-test", @@ -1645,7 +1645,7 @@ dependencies = [ [[package]] name = "smplx-regtest" -version = "0.1.1" +version = "0.2.0" dependencies = [ "electrsd", "hex", @@ -1659,7 +1659,7 @@ dependencies = [ [[package]] name = "smplx-sdk" -version = "0.1.1" +version = "0.2.0" dependencies = [ "bip39", "bitcoincore-rpc", @@ -1669,14 +1669,13 @@ dependencies = [ "minreq 3.0.0", "serde", "serde_json", - "sha2", "simplicityhl", "thiserror", ] [[package]] name = "smplx-std" -version = "0.1.1" +version = "0.2.0" dependencies = [ "either", "rand 0.10.3", @@ -1691,7 +1690,7 @@ dependencies = [ [[package]] name = "smplx-test" -version = "0.1.1" +version = "0.2.0" dependencies = [ "electrsd", "proc-macro2", @@ -1709,7 +1708,7 @@ dependencies = [ [[package]] name = "smplx-wasm" -version = "0.1.1" +version = "0.2.0" dependencies = [ "elements-miniscript", "hex", diff --git a/Cargo.toml b/Cargo.toml index b13ef97f..9a9cbb0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,13 +8,13 @@ license = "MIT" edition = "2024" [workspace.dependencies] -smplx-macros = { path = "./crates/macros", version = "=0.1.1" } -smplx-build = { path = "./crates/build", version = "=0.1.1" } -smplx-test = { path = "./crates/test", version = "=0.1.1" } -smplx-regtest = { path = "./crates/regtest", version = "=0.1.1" } -smplx-sdk = { path = "./crates/sdk", version = "=0.1.1", default-features = false } -smplx-std = { path = "./crates/simplex", version = "=0.1.1" } -smplx-wasm = { path = "./crates/wasm", version = "=0.1.1" } +smplx-macros = { path = "./crates/macros", version = "=0.2.0" } +smplx-build = { path = "./crates/build", version = "=0.2.0" } +smplx-test = { path = "./crates/test", version = "=0.2.0" } +smplx-regtest = { path = "./crates/regtest", version = "=0.2.0" } +smplx-sdk = { path = "./crates/sdk", version = "=0.2.0", default-features = false } +smplx-std = { path = "./crates/simplex", version = "=0.2.0" } +smplx-wasm = { path = "./crates/wasm", version = "=0.2.0" } rand_core = { version = "=0.10.1" } rand = { version = "=0.10.3" } diff --git a/README.md b/README.md index 0c0cadd9..860c49da 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ Simplex CLI provides the following commands: - `simplex regtest` - Spins up local Electrs + Elements nodes. - `simplex test` - Runs Simplex tests. Provide a `--fuzz` flag to run Simplex fuzz tests. - `simplex clean` - Cleans up generated artifacts and installed dependencies. +- `simplex fmt` - Formats SimplicityHL files according to the specified `simfmt.toml` or default [configuration](https://github.com/BlockstreamResearch/simfmt/blob/master/simfmt.toml). To view the available options, run the help command: @@ -150,11 +151,12 @@ We are open to any mind-blowing ideas! Please take a look at our [contributing g - [x] SimplicityHL storage compatibility. - [x] SimplicityHL dependencies management. - [x] Fuzz testing for SimplicityHL contracts. -- [ ] SDK support for confidential assets, taproot signer, and custom witness signatures. +- [x] Browser compatibility. +- [ ] SDK support for taproot signer and custom descriptors. - [ ] SimplicityHL contracts static analyzer. +- [ ] Test coverage reporting. - [ ] Local regtest 10x speedup. - [ ] Regtest cheat codes. -- [ ] Browser compatibility. - [ ] Comprehensive documentation. Check out the full roadmap [here](https://github.com/orgs/BlockstreamResearch/projects/3). diff --git a/crates/build/Cargo.toml b/crates/build/Cargo.toml index f5db101b..16fd997a 100644 --- a/crates/build/Cargo.toml +++ b/crates/build/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smplx-build" -version = "0.1.1" +version = "0.2.0" description = "Simplex build command internal implementation" license.workspace = true edition.workspace = true diff --git a/crates/build/src/collector.rs b/crates/build/src/collector.rs index f221c4ef..58f1b965 100644 --- a/crates/build/src/collector.rs +++ b/crates/build/src/collector.rs @@ -82,6 +82,15 @@ impl DepCollector { let loaded_simf_dir = CanonPath::canonicalize(&loaded_context.as_path().join(loaded_src_dir)) .map_err(BuildError::PathCanonicalization)?; + // A dependency may only define import contexts inside its own package. + if !loaded_simf_dir.starts_with(&loaded_context) { + return Err(BuildError::DependencySourceOutsidePackage { + dep_name: dep_name.clone(), + package_root: loaded_context.as_path().to_path_buf(), + source_dir: loaded_simf_dir.as_path().to_path_buf(), + }); + } + self.builder .add_dependency(simf_dir.clone(), dep_name.clone(), loaded_simf_dir.clone()); diff --git a/crates/build/src/error.rs b/crates/build/src/error.rs index d2455151..b85e132e 100644 --- a/crates/build/src/error.rs +++ b/crates/build/src/error.rs @@ -86,6 +86,12 @@ pub enum BuildError { #[error("Invalid generation path: '{0}'")] GenerationPath(String), + #[error("out_dir '{0}' holds the project or its src_dir")] + OutDirHoldsSources(PathBuf), + + #[error("Directory '{name}' in '{path}' must be a Rust identifier to become a module of the bindings")] + InvalidModuleName { name: String, path: PathBuf }, + #[error("Failed to extract content from path, err: '{0}'")] FailedToExtractContent(io::Error), @@ -112,6 +118,15 @@ pub enum BuildError { #[error("Failed to build dependency map: {0}")] DependencyMap(String), + #[error( + "Dependency '{dep_name}' src_dir resolves outside its package: '{source_dir}' (package root: '{package_root}')" + )] + DependencySourceOutsidePackage { + dep_name: String, + package_root: PathBuf, + source_dir: PathBuf, + }, + #[error("Failed to flatten program: {0}")] Flattening(String), diff --git a/crates/build/src/generator.rs b/crates/build/src/generator.rs index 9c2b9a04..382a0da4 100644 --- a/crates/build/src/generator.rs +++ b/crates/build/src/generator.rs @@ -82,6 +82,10 @@ impl ArtifactsGenerator { let simf_out_dir = out_dir.join(pathdiff); let mut metadata = Metadata::default(); + for simf in simfs { + Self::check_module_names(simf.as_ref(), base_dir)?; + } + let artifacts = simfs .iter() .map(|s| Self::process_simf(s.as_ref(), base_dir, validated_deps, &simf_out_dir, &mut metadata)) @@ -177,6 +181,29 @@ impl ArtifactsGenerator { }) } + /// Every directory between `base_dir` and `source` becomes a module of the bindings. + fn check_module_names(source: &Path, base_dir: &Path) -> Result<(), BuildError> { + let relative_path = source + .strip_prefix(base_dir) + .map_err(BuildError::NoBasePathForGeneration)?; + + for dir in relative_path.parent().into_iter().flat_map(Path::components) { + let Component::Normal(name) = dir else { + continue; + }; + let name = name.to_string_lossy(); + + if syn::parse_str::(&name).is_err() { + return Err(BuildError::InvalidModuleName { + name: name.into_owned(), + path: source.to_path_buf(), + }); + } + } + + Ok(()) + } + /// Arranges a flat list of artifacts into a tree mirroring the source directory layout. fn build_tree(artifacts: Vec) -> Result { let mut root = TreeNode::default(); @@ -391,3 +418,29 @@ impl ArtifactsGenerator { Ok(code) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn directories_must_be_rust_identifiers() { + let base = Path::new("/project/simf"); + + for valid in ["vault.simf", "vaults/vault.simf", "vaults/_2024/vault.simf"] { + assert!( + ArtifactsGenerator::check_module_names(&base.join(valid), base).is_ok(), + "{valid}" + ); + } + for invalid in ["my-contracts/vault.simf", "2024/vault.simf", "mod/vault.simf"] { + assert!( + matches!( + ArtifactsGenerator::check_module_names(&base.join(invalid), base), + Err(BuildError::InvalidModuleName { .. }) + ), + "{invalid}" + ); + } + } +} diff --git a/crates/build/src/macros/codegen.rs b/crates/build/src/macros/codegen.rs index 33abf147..9102cdaf 100644 --- a/crates/build/src/macros/codegen.rs +++ b/crates/build/src/macros/codegen.rs @@ -70,6 +70,7 @@ impl SimfContractMeta { /// Generates code necessary for fuzz testing. pub fn generate_program_trait_helpers_impl(&self) -> syn::Result { let args_struct_name = &self.args_struct.struct_name; + let witness_struct_name = &self.witness_struct.struct_name; let program_name = &self.program_struct_name; let argument_types = self.args_struct.generate_schema(); let witness_types = self.witness_struct.generate_schema(); @@ -88,6 +89,9 @@ impl SimfContractMeta { } impl ProgramSchema for #program_name { + type Arguments = #args_struct_name; + type Witness = #witness_struct_name; + fn argument_types() -> Vec<(TemplateProgramWitness, ResolvedType)> { vec![#(#argument_types),*] } @@ -104,7 +108,7 @@ impl SimfContractMeta { Ok(GeneratedProgramTraitHelperTokens { imports: quote! { - use super::{super::#program_name, #args_struct_name}; + use super::{super::#program_name, #args_struct_name, #witness_struct_name}; use simplex::program::{Program, ProgramFactory, ProgramSchema}; use simplex::simplicityhl::{Arguments, ResolvedType, TemplateProgramWitness}; use simplex::simplicityhl::num::{NonZeroPow2Usize, U256}; @@ -230,6 +234,14 @@ impl WitnessStruct { } } + impl TryFrom<&Arguments> for #struct_name { + type Error = String; + + fn try_from(args: &Arguments) -> Result { + Self::from_arguments(args) + } + } + impl From<#struct_name> for Arguments { fn from(#struct_param: #struct_name) -> Arguments { Arguments::from_map(HashMap::from([ @@ -320,6 +332,14 @@ impl WitnessStruct { } } + impl TryFrom<&WitnessValues> for #struct_name { + type Error = String; + + fn try_from(witness: &WitnessValues) -> Result { + Self::from_witness(witness) + } + } + impl From<#struct_name> for WitnessValues { fn from(#struct_param: #struct_name) -> WitnessValues { WitnessValues::from_map(HashMap::from([ diff --git a/crates/build/src/resolver.rs b/crates/build/src/resolver.rs index bd4aee90..c3665425 100644 --- a/crates/build/src/resolver.rs +++ b/crates/build/src/resolver.rs @@ -1,6 +1,8 @@ +#[cfg(feature = "full")] +use std::collections::BTreeSet; use std::fs; use std::hash::{DefaultHasher, Hash as _, Hasher as _}; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; #[cfg(feature = "full")] use globwalk::FileType; @@ -29,26 +31,52 @@ impl ArtifactsResolver { #[cfg(feature = "full")] pub fn resolve_files_to_build( root_dir: &Path, - src_dir: &String, - simfs: &[String], + src_dir: &impl AsRef, + simfs: &[impl AsRef], ) -> Result, BuildError> { - let base = root_dir.join(src_dir); + let files = Self::resolve_simf_files(root_dir, src_dir, simfs)?; let mut paths = Vec::new(); + for path in files { + let content = std::fs::read_to_string(&path)?; + + match parse::Program::parse_from_str(&content) { + Ok(program) if Self::rec_main_checker(program.items(), &FunctionName::main()) => paths.push(path), + Ok(_) => {} + Err(error) => eprintln!("warning: skipping '{}' as it doesn't parse: {error}", path.display()), + } + } + + Ok(paths) + } + + /// Resolves every source file matched by `simfs` beneath `src_dir` relative + /// to `project_root`. + /// + /// Returned paths are canonicalized, sorted, and deduplicated. Unlike + /// [`Self::resolve_files_to_build`], this does not require a source file to + /// contain a `main` function. + /// + /// # Errors + /// Returns a [`BuildError`] if a glob cannot be constructed or a matching + /// path cannot be canonicalized. + #[cfg(feature = "full")] + pub fn resolve_simf_files( + project_root: impl AsRef, + src_dir: impl AsRef, + simfs: &[impl AsRef], + ) -> Result, BuildError> { + let base = project_root.as_ref().join(src_dir.as_ref()); let walker = globwalk::GlobWalkerBuilder::from_patterns(base, simfs) .follow_links(true) .file_type(FileType::FILE) .build()? .filter_map(Result::ok); + let mut paths = BTreeSet::new(); for img in walker { - let path = img.path().to_path_buf().canonicalize()?; - let content = std::fs::read_to_string(&path)?; - - if Self::contains_main(&content) { - paths.push(path); - } + paths.insert(img.path().to_path_buf().canonicalize()?); } Ok(paths) @@ -76,6 +104,25 @@ impl ArtifactsResolver { Ok(path_outer) } + /// Resolves `out_dir` like [`Self::resolve_local_dir`], refusing one that holds the project or its sources. + pub fn resolve_out_dir( + root_dir: &Path, + out_dir: &impl AsRef, + src_dir: &impl AsRef, + ) -> Result { + let resolved = Self::resolve_local_dir(root_dir, out_dir)?; + + let out = Self::normalize(&resolved); + let root = Self::normalize(root_dir); + let src = Self::normalize(&root_dir.join(src_dir)); + + if root.starts_with(&out) || src.starts_with(&out) { + return Err(BuildError::OutDirHoldsSources(resolved)); + } + + Ok(resolved) + } + /// Builds a [`ValidatedDeps`] by recursively walking the dependency tree /// starting from the current working directory. /// @@ -148,8 +195,7 @@ impl ArtifactsResolver { let key = std::iter::once(url.to_owned()) .chain(reference) .chain(package) - .collect::>() - .join("@"); + .collect::>(); let mut hasher = DefaultHasher::new(); key.hash(&mut hasher); @@ -160,6 +206,23 @@ impl ArtifactsResolver { Some(PathBuf::from(dir_name)) } + /// Resolves `.` and `..` without touching the filesystem, as `path` may not exist yet. + fn normalize(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + other => normalized.push(other), + } + } + + normalized + } + /// Encodes `value` in base58, left-padded to a fixed [`BASE58_U64_LEN`] characters. fn encode_base58(mut value: u64) -> String { let mut digits = [BASE58_ALPHABET[0]; BASE58_U64_LEN]; @@ -172,20 +235,9 @@ impl ArtifactsResolver { digits.iter().map(|&digit| char::from(digit)).collect() } - #[cfg(feature = "full")] - /// Checks whether the source declares a `fn main(...)`, - /// finding it even when nested inside `mod { ... }` blocks. - fn contains_main(source: &str) -> bool { - let Ok(parsed_program) = parse::Program::parse_from_str(source) else { - return false; - }; - - Self::rec_main_checker(parsed_program.items(), &FunctionName::main()) - } - #[cfg(feature = "full")] /// Recursively searches `items` (descending into nested modules) for a - /// function named `main`. + /// function named `main`, finding it even when nested inside `mod { ... }` blocks. fn rec_main_checker(items: &[parse::Item], main_name: &FunctionName) -> bool { items.iter().any(|item| match item { parse::Item::Function(func) => func.name() == main_name, @@ -194,3 +246,23 @@ impl ArtifactsResolver { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn out_dir_cannot_hold_the_project_or_its_sources() { + let root = Path::new("/project"); + + assert!(ArtifactsResolver::resolve_out_dir(root, &"src/artifacts", &"simf").is_ok()); + + for out_dir in ["", ".", "..", "simf", "./simf/../simf", "/"] { + assert!( + ArtifactsResolver::resolve_out_dir(root, &out_dir, &"simf").is_err(), + "{out_dir}" + ); + } + assert!(ArtifactsResolver::resolve_out_dir(root, &"contracts", &"contracts/simf").is_err()); + } +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 465ab50b..9112ad2e 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smplx-cli" -version = "0.1.1" +version = "0.2.0" description = "Simplex cli with various utilities to manage a simplicity project" license.workspace = true edition.workspace = true diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 1e73e34b..69f5b354 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -1,5 +1,4 @@ use std::env; -use std::path::PathBuf; use clap::Parser; @@ -8,6 +7,7 @@ use smplx_build::DependencyConfig; use crate::commands::Command; use crate::commands::build::Build; use crate::commands::clean::Clean; +use crate::commands::fmt::Format; use crate::commands::init::Init; use crate::commands::install::Install; use crate::commands::regtest::Regtest; @@ -18,10 +18,8 @@ use crate::error::CliError; #[derive(Debug, Parser)] #[command(name = "Simplex")] -#[command(version, about = "A blazingly-fast, ux-first simplicity development framework")] +#[command(version, about = "A blazingly-fast, ux-first Simplicity development framework")] pub struct Cli { - pub config: Option, - #[command(subcommand)] pub command: Command, } @@ -52,7 +50,7 @@ impl Cli { Ok(Init::run(simplex_conf_path)?) } Command::Config => { - let config_path = Config::get_default_path()?; + let config_path = Config::get_ancestors_config_path()?; let loaded_config = Config::load(config_path)?; println!("{loaded_config:#?}"); @@ -60,28 +58,30 @@ impl Cli { Ok(()) } Command::Test { args, flags } => { - let config_path = Config::get_default_path()?; + let config_path = Config::get_ancestors_config_path()?; let loaded_config = Config::load(config_path)?; Ok(Test::run(loaded_config.test, args, flags)?) } Command::Regtest => { - let config_path = Config::get_default_path()?; + let config_path = Config::get_ancestors_config_path()?; let loaded_config = Config::load(config_path)?; Ok(Regtest::run(&loaded_config.regtest)?) } Command::Install { deps } => { - let config_path = Config::get_default_path()?; + let config_path = Config::get_ancestors_config_path()?; + let root_dir = Config::get_project_root(&config_path)?; + DependencyConfig::add_dependency_to(&config_path, deps).map_err(ConfigError::from)?; let loaded_config = Config::load(config_path)?; - Ok(Install::run(&loaded_config.dependencies)?) + Ok(Install::run(&root_dir, &loaded_config.dependencies)?) } Command::Build => { - let root_dir = env::current_dir()?; - let config_path = Config::get_default_path()?; - let loaded_config = Config::load(config_path)?; + let config_path = Config::get_ancestors_config_path()?; + let root_dir = Config::get_project_root(&config_path)?; + let loaded_config = Config::load(&config_path)?; Ok(Build::run( &root_dir, @@ -90,12 +90,13 @@ impl Cli { )?) } Command::Clean { flags } => { - let root_dir = env::current_dir()?; - let config_path = Config::get_default_path()?; + let config_path = Config::get_ancestors_config_path()?; + let root_dir = Config::get_project_root(&config_path)?; let loaded_config = Config::load(&config_path)?; - Ok(Clean::run(&root_dir, &loaded_config.build.out_dir, flags)?) + Ok(Clean::run(&root_dir, &loaded_config.build, flags)?) } + Command::Fmt { opts } => Ok(Format::run(opts)?), } } } diff --git a/crates/cli/src/commands/build.rs b/crates/cli/src/commands/build.rs index 384c4e18..9ceda544 100644 --- a/crates/cli/src/commands/build.rs +++ b/crates/cli/src/commands/build.rs @@ -13,7 +13,7 @@ impl Build { /// # Errors /// Returns a `CommandError` if it fails to resolve directories or files, or if artifact generation encounters an error. pub fn run(root_dir: &Path, config: &BuildConfig, deps: &DependencyConfig) -> Result<(), CommandError> { - let output_dir = ArtifactsResolver::resolve_local_dir(root_dir, &config.out_dir)?; + let output_dir = ArtifactsResolver::resolve_out_dir(root_dir, &config.out_dir, &config.src_dir)?; let src_dir = ArtifactsResolver::resolve_local_dir(root_dir, &config.src_dir)?; // NOTE: Assumes that remappings are already installed diff --git a/crates/cli/src/commands/clean.rs b/crates/cli/src/commands/clean.rs index c06f5c75..4ec03580 100644 --- a/crates/cli/src/commands/clean.rs +++ b/crates/cli/src/commands/clean.rs @@ -1,8 +1,8 @@ use std::path::Path; use std::{fmt::Display, fs, path::PathBuf}; -use smplx_build::ArtifactsResolver; use smplx_build::config::DEFAULT_DEPENDENCY_DIR; +use smplx_build::{ArtifactsResolver, BuildConfig}; use crate::commands::CleanFlags; use crate::commands::error::CleanError; @@ -20,8 +20,8 @@ impl Clean { /// # Errors /// Returns a `CommandError` if the artifacts directory cannot be resolved /// or if removing any directory fails. - pub fn run(root_dir: &Path, artifacts: &impl AsRef, flags: &CleanFlags) -> Result<(), CommandError> { - let artifacts_dir = ArtifactsResolver::resolve_local_dir(root_dir, artifacts) + pub fn run(root_dir: &Path, config: &BuildConfig, flags: &CleanFlags) -> Result<(), CommandError> { + let artifacts_dir = ArtifactsResolver::resolve_out_dir(root_dir, &config.out_dir, &config.src_dir) .map_err(|err| CleanError::ResolveOutDir(err.to_string()))?; let mut to_remove = vec![artifacts_dir]; diff --git a/crates/cli/src/commands/core.rs b/crates/cli/src/commands/core.rs index e09f9a5b..0f75a2ee 100644 --- a/crates/cli/src/commands/core.rs +++ b/crates/cli/src/commands/core.rs @@ -34,6 +34,11 @@ pub enum Command { #[command(flatten)] flags: CleanFlags, }, + /// Formats the configured `SimplicityHL` source files using simfmt. + Fmt { + #[command(flatten)] + opts: FormatOpts, + }, } #[allow(clippy::struct_excessive_bools)] @@ -83,3 +88,31 @@ pub struct CleanFlags { #[arg(long = "all")] pub remove_all: bool, } + +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Args)] +pub struct FormatOpts { + /// Path to the file to format. + #[arg(value_hint = clap::ValueHint::FilePath, value_name = "PATH", num_args(1..))] + pub files: Vec, + + /// Run simfmt in check mode. + #[arg(long = "check")] + pub check: bool, + + /// No output printed to stdout. + #[arg(short = 'q', long = "quiet")] + pub quiet: bool, + + /// Use verbose output. + #[arg(short = 'v', long = "verbose")] + pub verbose: bool, + + /// List files with differences instead of showing the full diff. + #[arg(long = "short")] + pub short: bool, + + /// Options passed directly to simfmt. + #[arg(id = "simfmt_options", raw = true)] + pub simfmt_options: Vec, +} diff --git a/crates/cli/src/commands/error.rs b/crates/cli/src/commands/error.rs index 207fffd6..3ccf9c20 100644 --- a/crates/cli/src/commands/error.rs +++ b/crates/cli/src/commands/error.rs @@ -23,6 +23,9 @@ pub enum CommandError { #[error(transparent)] Install(#[from] InstallError), + #[error(transparent)] + Fmt(#[from] FmtError), + #[error("IO error: {0}")] Io(#[from] std::io::Error), @@ -36,6 +39,27 @@ pub enum CommandError { TestProcessTerminated, } +#[derive(thiserror::Error, Debug)] +pub enum FmtError { + #[error("Quiet mode and verbose mode are not compatible")] + ConflictingVerbosity, + + #[error("Invalid --message-format value: {0}. Allowed values are: short|human")] + InvalidMessageFormat(String), + + #[error("no files matched the configured simf_files patterns under '{}'", .0.display())] + NoFiles(PathBuf), + + #[error("Failed to determine the simplex executable path: {0}")] + CurrentExecutable(std::io::Error), + + #[error("could not run simfmt at '{}': {source}", binary.display())] + RunSimfmt { binary: PathBuf, source: std::io::Error }, + + #[error(transparent)] + Config(#[from] crate::config::error::ConfigError), +} + #[derive(thiserror::Error, Debug)] pub enum InitError { #[error("Failed to open file '{1}': {0}")] @@ -83,11 +107,8 @@ pub enum InstallError { #[error("Invalid Git repository URL (cannot extract name): '{0}'")] InvalidUrl(String), - #[error("Failed to create directory at '{1}': {0}")] - CreateDir(std::io::Error, PathBuf), - - #[error("Failed to read directory at '{1}': {0}")] - ReadDir(std::io::Error, PathBuf), + #[error("Dependency at '{0}' is incomplete, remove the directory and run install again")] + Incomplete(PathBuf), #[error("Failed to execute git process for '{1}': {0}")] GitExecution(std::io::Error, String), @@ -98,6 +119,9 @@ pub enum InstallError { #[error("Package directory '{0}' not found in repository: '{1}'")] PackageNotFound(String, String), - #[error("Failed to move package into '{1}': {0}")] - MovePackage(std::io::Error, PathBuf), + #[error("Failed to move the checkout into '{1}': {0}")] + MoveCheckout(std::io::Error, PathBuf), + + #[error("Repository '{0}' has no Simplex.toml in the installed directory")] + MissingConfig(String), } diff --git a/crates/cli/src/commands/fmt.rs b/crates/cli/src/commands/fmt.rs new file mode 100644 index 00000000..b3c5073f --- /dev/null +++ b/crates/cli/src/commands/fmt.rs @@ -0,0 +1,338 @@ +use std::collections::BTreeSet; +use std::env; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus, Stdio}; + +use smplx_build::{ArtifactsResolver, BuildConfig}; + +use crate::commands::FormatOpts; +use crate::commands::error::{CommandError, FmtError}; +use crate::config::Config; + +const SIMFMT_BIN_NAME: &str = "simfmt"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Verbosity { + Verbose, + Normal, + Quiet, +} + +pub struct Format; + +impl Format { + /// Formats every configured `.simf` source file in one `simfmt` process. + /// + /// Explicit files and informational requests do not require a config. + /// + /// # Errors + /// Returns a [`CommandError`] if the options or project configuration are + /// invalid, source discovery fails, no source files match, or `simfmt` cannot + /// be executed. + pub fn run(opts: &FormatOpts) -> Result<(), CommandError> { + let verbosity = Self::verbosity(opts)?; + + let files = if Self::is_info_request(opts) { + Vec::default() + } else if opts.files.is_empty() { + let config_path = Config::get_ancestors_config_path().map_err(FmtError::from)?; + let project_root = Config::get_project_root(&config_path).map_err(FmtError::from)?; + let loaded_config = Config::load(&config_path).map_err(FmtError::from)?; + + Self::resolve_files(&loaded_config.build, project_root)? + .into_iter() + .collect::>() + } else { + opts.files + .iter() + .map(|path| path.canonicalize().unwrap_or_else(|_| path.clone())) + .collect::>() + }; + + let args = Self::build_simfmt_args(opts, &files); + let signal = Self::run_simfmt(&args, verbosity)?; + + Self::flush(signal) + } + + fn is_info_request(opts: &FormatOpts) -> bool { + opts.simfmt_options.iter().any(|arg| { + ["--print-config", "-h", "--help", "-V", "--version"].contains(&arg.as_str()) + || arg.starts_with("--help=") + || arg.starts_with("--print-config=") + }) + } + + fn resolve_files(config: &BuildConfig, project_root: impl AsRef) -> Result, CommandError> { + let src_dir = project_root.as_ref().join(&config.src_dir); + let files = ArtifactsResolver::resolve_simf_files(project_root, &config.src_dir, &config.simf_files)?; + + if files.is_empty() { + return Err(FmtError::NoFiles(src_dir).into()); + } + + Ok(files) + } + + fn verbosity(opts: &FormatOpts) -> Result { + match (opts.verbose, opts.quiet) { + (false, false) => Ok(Verbosity::Normal), + (false, true) => Ok(Verbosity::Quiet), + (true, false) => Ok(Verbosity::Verbose), + (true, true) => Err(FmtError::ConflictingVerbosity), + } + } + + fn build_simfmt_args(opts: &FormatOpts, files: &[PathBuf]) -> Vec { + let mut simfmt_args = Vec::with_capacity(opts.simfmt_options.len() + 3); + + if opts.quiet { + simfmt_args.push(OsString::from("--quiet")); + } + if opts.verbose { + simfmt_args.push(OsString::from("--verbose")); + } + if opts.check && !opts.simfmt_options.iter().any(|arg| arg == "--check") { + simfmt_args.push(OsString::from("--check")); + } + simfmt_args.extend(opts.simfmt_options.iter().map(OsString::from)); + + if opts.short + && !opts + .simfmt_options + .iter() + .any(|arg| arg == "-l" || arg == "--files-with-diff") + { + simfmt_args.push(OsString::from("-l")); + } + + let mut args = Vec::with_capacity(files.len() + simfmt_args.len()); + + for file in files { + args.push(file.as_os_str().to_owned()); + } + + args.extend(simfmt_args); + args + } + + fn run_simfmt(args: &[OsString], verbosity: Verbosity) -> Result { + let binary = Self::simfmt_binary()?; + let mut command = Command::new(&binary); + command + .args(args) + .stdin(Stdio::inherit()) + .stderr(Stdio::inherit()) + .stdout(if verbosity == Verbosity::Quiet { + Stdio::null() + } else { + Stdio::inherit() + }); + + if verbosity == Verbosity::Verbose { + println!("{command:?}"); + } + + let status = command + .status() + .map_err(|source| FmtError::RunSimfmt { binary, source })?; + + Ok(Self::normalize_status(status)) + } + + /// Resolves the file path to the `simfmt` binary in the same directory where `simplex` is situated. + /// Given that `simfmt` would be placed in one directory with `simplex`, this is suitable for our usecase. + /// + /// # Warning + /// Can fail to search for binary when env isn't set and `simfmt` binary lies in the other directory. + /// Even when `simfmt` is visible through PATH. + #[inline] + fn simfmt_binary() -> Result { + Ok(env::current_exe() + .map_err(FmtError::CurrentExecutable)? + .with_file_name(SIMFMT_BIN_NAME)) + } + + /// Flushes the STDOUT buffer and exits with the formatter's exit status. + fn flush(status: i32) -> Result<(), CommandError> { + use std::io::Write; + + // Flush buffered standard output before exiting. + std::io::stdout().flush()?; + + // Exit with the formatter's success or failure status. + std::process::exit(status); + } + + fn normalize_status(status: ExitStatus) -> i32 { + const SUCCESS: i32 = 0; + const FAILURE: i32 = 1; + + if status.success() { SUCCESS } else { FAILURE } + } +} + +#[cfg(test)] +mod tests { + use std::ffi::{OsStr, OsString}; + use std::path::{Path, PathBuf}; + + use clap::Parser; + use smplx_build::BuildConfig; + + use crate::Cli; + use crate::commands::Command as SimplexCommand; + + use super::{Format, FormatOpts}; + + fn fmt_opts(args: impl IntoIterator) -> FormatOpts { + let cli = Cli::try_parse_from(args).expect("arguments should parse"); + match cli.command { + SimplexCommand::Fmt { opts } => opts, + _ => panic!("expected fmt command"), + } + } + + #[test] + fn parses_default_options() { + let opts = fmt_opts(["simplex", "fmt"]); + + assert!(!opts.quiet); + assert!(!opts.verbose); + assert!(!opts.check); + assert!(!opts.short); + assert_eq!(opts.simfmt_options, [] as [std::string::String; 0]); + } + + #[test] + fn parses_all_options_and_raw_simfmt_arguments() { + let opts = fmt_opts([ + "simplex", "fmt", "--quiet", "--short", "--check", "--", "--emit", "stdout", + ]); + + assert!(opts.quiet); + assert!(!opts.verbose); + assert!(opts.check); + assert!(opts.short); + assert_eq!(opts.simfmt_options, ["--emit", "stdout"]); + } + + #[test] + fn raw_simfmt_arguments_require_separator() { + assert!(Cli::try_parse_from(["simplex", "fmt", "--emit", "stdout"]).is_err()); + assert!(Cli::try_parse_from(["simplex", "fmt", "--", "--emit", "stdout"]).is_ok()); + + assert!(Cli::try_parse_from(["simplex", "fmt", "--color", "auto"]).is_err()); + assert!(Cli::try_parse_from(["simplex", "fmt", "--", "--color", "auto"]).is_ok()); + } + + #[test] + fn rejects_unknown_wrapper_arguments() { + assert!(Cli::try_parse_from(["simplex", "fmt", "--package", "demo"]).is_err()); + assert!(Cli::try_parse_from(["simplex", "fmt", "--all"]).is_err()); + } + + #[test] + fn builds_deterministic_simfmt_arguments() { + let opts = fmt_opts(["simplex", "fmt", "--verbose", "--check", "--", "--emit", "files"]); + let files = [PathBuf::from("/tmp/a.simf"), PathBuf::from("/tmp/path/spaces/b.simf")]; + + let args = Format::build_simfmt_args(&opts, &files); + + assert_eq!( + args, + [ + OsString::from("/tmp/a.simf"), + OsString::from("/tmp/path/spaces/b.simf"), + OsString::from("--verbose"), + OsString::from("--check"), + OsString::from("--emit"), + OsString::from("files"), + ] + ); + } + + #[test] + fn builds_deterministic_simfmt_arguments_with_files() { + let opts = fmt_opts([ + "simplex", + "fmt", + "/tmp/a.simf", + "--verbose", + "--check", + "/tmp/b.simf", + "/tmp/path/spaces/b.simf", + "--", + "--emit", + "files", + ]); + + let args = Format::build_simfmt_args(&opts, &opts.files); + + assert_eq!( + args, + [ + OsString::from("/tmp/a.simf"), + OsString::from("/tmp/b.simf"), + OsString::from("/tmp/path/spaces/b.simf"), + OsString::from("--verbose"), + OsString::from("--check"), + OsString::from("--emit"), + OsString::from("files"), + ] + ); + } + + #[test] + fn converts_short_message_format_to_list_files() { + let opts = fmt_opts(["simplex", "fmt", "--short"]); + let args = Format::build_simfmt_args(&opts, &["/tmp/a.simf".into()]); + + assert_eq!(args.last(), Some(&OsString::from("-l"))); + } + + #[test] + fn does_not_duplicate_existing_list_files_flag() { + let opts = fmt_opts(["simplex", "fmt", "--short", "--", "--files-with-diff"]); + let args = Format::build_simfmt_args(&opts, &["/tmp/a.simf".into()]); + + assert_eq!(args.iter().filter(|arg| *arg == "--files-with-diff").count(), 1); + assert!(!args.iter().any(|arg| arg == "-l")); + } + + #[test] + fn does_not_duplicate_raw_check_flag() { + let opts = fmt_opts(["simplex", "fmt", "--check", "--", "--check"]); + let args = Format::build_simfmt_args(&opts, &["/tmp/a.simf".into()]); + + assert_eq!(args.iter().filter(|arg| *arg == "--check").count(), 1); + } + + #[test] + fn resolves_all_configured_files_in_sorted_order() { + let project_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures"); + let config = BuildConfig { + simf_files: vec!["*.simf".into(), "**/*.simf".into()], + src_dir: "simf".into(), + out_dir: "None".into(), + }; + + let files = Format::resolve_files(&config, &project_root).expect("fixture files should resolve"); + + assert_eq!(files.len(), 17); + assert!(files.iter().all(|path| path.extension() == Some(OsStr::new("simf")))); + } + + #[test] + fn rejects_an_empty_formatter_workspace() { + let project_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures"); + let config = BuildConfig { + simf_files: vec!["no-such-file-*.simf".into()], + src_dir: "simf".into(), + out_dir: "None".into(), + }; + + assert!(Format::resolve_files(&config, &project_root).is_err()); + } +} diff --git a/crates/cli/src/commands/init.rs b/crates/cli/src/commands/init.rs index 25cd84ef..1416edbb 100644 --- a/crates/cli/src/commands/init.rs +++ b/crates/cli/src/commands/init.rs @@ -45,6 +45,12 @@ impl Init { manifest["package"]["version"] = toml_edit::value("0.1.0"); manifest["package"]["edition"] = toml_edit::value("2024"); + // Tests spend most of their time in the dependencies. Optimize them even in dev builds + let profile = "\n[profile.dev.package.\"*\"]\nopt-level = 3\n" + .parse::() + .expect("the profile is valid TOML"); + manifest["profile"] = profile["profile"].clone(); + let mut dep_table = toml_edit::Table::default(); dep_table.insert( SIMPLEX_CRATE_NAME, @@ -54,6 +60,7 @@ impl Init { ); manifest["dependencies"] = toml_edit::Item::Table(dep_table); + manifest }; diff --git a/crates/cli/src/commands/install.rs b/crates/cli/src/commands/install.rs index d1d92b66..d6caa9ec 100644 --- a/crates/cli/src/commands/install.rs +++ b/crates/cli/src/commands/install.rs @@ -16,7 +16,8 @@ pub struct Install; pub struct InstalledRepos(Vec); impl Install { - /// Installs all git-based dependencies into the local [`DEFAULT_DEPENDENCY_DIR`] directory. + /// Installs all git-based dependencies into the project's [`DEFAULT_DEPENDENCY_DIR`] directory. + /// The dependency directory is resolved relative to `root_dir`. /// /// Clones each dependency declared in [`DEFAULT_DEPENDENCY_DIR`] that specifies a `git` source, /// then reports which repositories were installed. @@ -26,13 +27,13 @@ impl Install { /// Returns a [`CommandError`] if: /// - The [`DEFAULT_DEPENDENCY_DIR`] directory cannot be created (e.g. permission denied). /// - Any repository fails to clone or install. - pub fn run(deps: &DependencyConfig) -> Result<(), CommandError> { + pub fn run(root_dir: &Path, deps: &DependencyConfig) -> Result<(), CommandError> { let mut installed_repos = Vec::::new(); - let deps_dir = Path::new(DEFAULT_DEPENDENCY_DIR); - fs::create_dir_all(DEFAULT_DEPENDENCY_DIR)?; + let deps_dir = root_dir.join(DEFAULT_DEPENDENCY_DIR); + fs::create_dir_all(&deps_dir)?; - Self::install_repos(deps, deps_dir, &mut installed_repos)?; + Self::install_repos(deps, &deps_dir, &mut installed_repos)?; let installed_repos = InstalledRepos(installed_repos); if installed_repos.0.is_empty() { @@ -99,10 +100,6 @@ impl Install { continue; } - if !target_dir.exists() { - fs::create_dir_all(&target_dir).map_err(|e| InstallError::CreateDir(e, target_dir.clone()))?; - } - Self::clone_repo( git_repo_url, reference.as_ref(), @@ -120,6 +117,9 @@ impl Install { /// Clones `url` into `target_dir`, checking out `reference`. /// When `package` is set, only that directory is downloaded and `target_dir` holds /// its contents directly, without the rest of the repository or its `.git`. + /// + /// Git works in a staging directory that becomes `target_dir` only once the checkout is complete, + /// so an interrupted or concurrent install doesn't leave a partial `target_dir` that looks installed. fn clone_repo( url: &str, reference: Option<&GitRef>, @@ -127,62 +127,76 @@ impl Install { target_dir: &Path, installed_repos: &mut Vec, ) -> Result<(), InstallError> { - let is_empty = fs::read_dir(target_dir) - .map_err(|err| InstallError::ReadDir(err, target_dir.to_path_buf()))? - .next() - .is_none(); - - // Consider it installed and skip cloning. - if !is_empty { + if Self::is_installed(target_dir)? { return Ok(()); } - // Git keeps a package at its in-repo path, so it is cloned into a sibling - // staging dir first and then moved out of it. - let clone_dir = match package { - Some(_) => { - let mut staging = target_dir.as_os_str().to_owned(); - staging.push(".tmp"); - let staging = PathBuf::from(staging); + // Each process stages separately, so concurrent installs don't clone over each other + let mut staging_name = std::ffi::OsString::from("."); + staging_name.push(target_dir.file_name().unwrap_or_default()); + staging_name.push(format!(".{}.tmp", std::process::id())); + let staging = target_dir.with_file_name(staging_name); - let _ = fs::remove_dir_all(&staging); - staging - } - None => target_dir.to_path_buf(), - }; + let _ = fs::remove_dir_all(&staging); + let installed = Self::stage_and_move(url, reference, package, &staging, target_dir); + let _ = fs::remove_dir_all(&staging); + + if installed? { + installed_repos.push(target_dir.to_path_buf()); + } + + Ok(()) + } - for mut command in Self::git_commands(url, reference, package, &clone_dir) { + /// Clones into `staging` and moves the checkout, or its `package`, to `target_dir`. + /// `false` when a concurrent install got there first. + fn stage_and_move( + url: &str, + reference: Option<&GitRef>, + package: Option<&str>, + staging: &Path, + target_dir: &Path, + ) -> Result { + for mut command in Self::git_commands(url, reference, package, staging) { let output = command .output() .map_err(|err| InstallError::GitExecution(err, url.to_owned()))?; if !output.status.success() { - // Force to remove file, if something went wrong - let _ = std::fs::remove_dir_all(&clone_dir); return Err(InstallError::GitCloneFailed(url.to_owned())); } } - if let Some(package) = package { - let package_dir = clone_dir.join(package); - + let checkout = match package { // Git succeeds even if `package` doesn't exist at this ref, leaving an empty checkout. - let moved = if package_dir.is_dir() { - // `target_dir` is still empty; `rename` can't replace a directory on every platform. - fs::remove_dir(target_dir) - .and_then(|()| fs::rename(&package_dir, target_dir)) - .map_err(|err| InstallError::MovePackage(err, target_dir.to_path_buf())) - } else { - Err(InstallError::PackageNotFound(package.to_owned(), url.to_owned())) - }; + Some(package) if !staging.join(package).is_dir() => { + return Err(InstallError::PackageNotFound(package.to_owned(), url.to_owned())); + } + Some(package) => staging.join(package), + None => staging.to_path_buf(), + }; + + if !checkout.join(smplx_build::CONFIG_FILENAME).is_file() { + return Err(InstallError::MissingConfig(url.to_owned())); + } - let _ = fs::remove_dir_all(&clone_dir); - moved?; + match fs::rename(&checkout, target_dir) { + Ok(()) => Ok(true), + Err(_) if Self::is_installed(target_dir)? => Ok(false), + Err(err) => Err(InstallError::MoveCheckout(err, target_dir.to_path_buf())), } + } - installed_repos.push(target_dir.to_path_buf()); + fn is_installed(target_dir: &Path) -> Result { + if target_dir.join(smplx_build::CONFIG_FILENAME).is_file() { + return Ok(true); + } - Ok(()) + if target_dir.exists() { + return Err(InstallError::Incomplete(target_dir.to_path_buf())); + } + + Ok(false) } /// Builds the git commands, in execution order, that clone `url` into `target_dir` @@ -196,9 +210,15 @@ impl Install { clone.args(["clone", "--filter=blob:none", "--no-checkout"]); let checkout_target = match reference { - Some(GitRef::Rev(rev)) => rev.as_str(), - Some(GitRef::Tag(name) | GitRef::Branch(name)) => { - clone.args(["--depth", "1", "--branch", name]); + Some(GitRef::Rev(rev)) => rev.as_ref(), + Some(GitRef::Tag(name)) => { + let qualified_name = format!("refs/tags/{name}"); + clone.args(["--depth", "1", "--revision", &qualified_name]); + "HEAD" + } + Some(GitRef::Branch(name)) => { + let qualified_name = format!("refs/heads/{name}"); + clone.args(["--depth", "1", "--revision", &qualified_name]); "HEAD" } None => { @@ -224,7 +244,7 @@ impl Install { checkout .arg("-C") .arg(target_dir) - .args(["checkout", checkout_target, "--"]); + .args(["checkout", "--detach", checkout_target, "--"]); commands.push(checkout); commands @@ -254,6 +274,137 @@ impl Display for InstalledRepos { mod tests { use super::*; + const URL: &str = "https://example.invalid/org/repo.git"; + const TARGET: &str = r#"/unused/"git fixture""#; + + fn assert_command(command: &Command, args: &[&str]) { + assert_eq!(command.get_program(), "git"); + + let actual: Vec<_> = command.get_args().map(|arg| arg.to_str().unwrap()).collect(); + + assert_eq!(actual, args); + } + + #[test] + fn git_commands_revisions_appended_correctly() { + for rev in [ + "dae86e1950b1277e545cee180551750029cfe735", + "dae86e", + "v1.7.4.2-679-g3bee7fb", + "master", + "refs/heads/master", + "HEAD", + "@", + "master@{yesterday}", + "HEAD@{5 minutes ago}", + "master@{1}", + "@{1}", + "@{-1}", + "master@{upstream}", + "@{push}", + "HEAD^", + "v1.5.1^0", + "master~3", + "v0.99.8^{commit}", + "v0.99.8^{object}", + "v0.99.8^{tag}", + "v0.99.8^{}", + "HEAD^{/fix nasty bug}", + ":/fix nasty bug", + ":/^foo", + ":/!-foo", + ":/!!foo", + ] { + let reference = GitRef::Rev(rev.into()); + let commands = Install::git_commands(URL, Some(&reference), None, Path::new(TARGET)); + + assert_eq!(commands.len(), 2, "revision: {rev}"); + let (clone, checkout) = (&commands[0], &commands[1]); + + assert_command( + clone, + &["clone", "--filter=blob:none", "--no-checkout", "--", URL, TARGET], + ); + assert_command(checkout, &["-C", TARGET, "checkout", "--detach", rev, "--"]); + } + } + + #[test] + fn git_commands_tag_and_branch_are_nonoverlapping() { + // An identically named branch must not substitute for a requested tag. + for (reference, qualified_name) in [ + (GitRef::Tag("release".into()), "refs/tags/release"), + (GitRef::Branch("release".into()), "refs/heads/release"), + ] { + let commands = Install::git_commands(URL, Some(&reference), None, Path::new(TARGET)); + + assert_eq!(commands.len(), 2); + let (clone, checkout) = (&commands[0], &commands[1]); + + assert_command( + clone, + &[ + "clone", + "--filter=blob:none", + "--no-checkout", + "--depth", + "1", + "--revision", + qualified_name, + "--", + URL, + TARGET, + ], + ); + assert_command(checkout, &["-C", TARGET, "checkout", "--detach", "HEAD", "--"]); + } + } + + #[test] + fn git_commands_using_shallow_clone_by_default() { + let commands = Install::git_commands(URL, None, None, Path::new(TARGET)); + + assert_eq!(commands.len(), 2); + let (clone, checkout) = (&commands[0], &commands[1]); + + assert_command( + clone, + &[ + "clone", + "--filter=blob:none", + "--no-checkout", + "--depth", + "1", + "--", + URL, + TARGET, + ], + ); + assert_command(checkout, &["-C", TARGET, "checkout", "--detach", "HEAD", "--"]); + } + + #[test] + fn sparse_checkout_is_before_checking_out_the_revision() { + let reference = GitRef::Rev(":/fix nasty bug".into()); + let commands = Install::git_commands(URL, Some(&reference), Some("libs/math"), Path::new(TARGET)); + + assert_eq!(commands.len(), 3); + let (clone, sparse_checkout, checkout) = (&commands[0], &commands[1], &commands[2]); + + assert_command( + clone, + &["clone", "--filter=blob:none", "--no-checkout", "--", URL, TARGET], + ); + assert_command( + sparse_checkout, + &["-C", TARGET, "sparse-checkout", "set", "--no-cone", "/libs/math/"], + ); + assert_command( + checkout, + &["-C", TARGET, "checkout", "--detach", ":/fix nasty bug", "--"], + ); + } + fn git(dir: &Path, args: &[&str]) { let status = Command::new("git") .arg("-C") @@ -274,6 +425,77 @@ mod tests { git(dir, &["commit", "--quiet", "-m", "init"]); } + #[test] + fn failed_install_leaves_nothing_behind() { + let root = std::env::temp_dir().join(format!("smplx-install-failed-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let deps_dir = root.join("deps"); + fs::create_dir_all(&deps_dir).unwrap(); + + let url = format!("file://{}", root.join("missing").display()); + let deps = + DependencyConfig::from_source(&format!("[dependencies]\nmissing = {{ git = \"{url}\" }}\n")).unwrap(); + + assert!(Install::install_repos(&deps, &deps_dir, &mut Vec::new()).is_err()); + assert_eq!( + fs::read_dir(&deps_dir).unwrap().count(), + 0, + "neither a target nor a staging dir is left" + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn incomplete_install_is_reported() { + let root = std::env::temp_dir().join(format!("smplx-install-incomplete-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + + let url = format!("file://{}", root.join("a").display()); + init_repo(&root.join("a"), ""); + + let deps = DependencyConfig::from_source(&format!("[dependencies]\na = {{ git = \"{url}\" }}\n")).unwrap(); + let deps_dir = root.join("deps"); + let target_dir = deps_dir.join(ArtifactsResolver::generate_hashed_repo_path(&url, None, None).unwrap()); + + // An install killed after `git clone --no-checkout` left only the repository's `.git` + fs::create_dir_all(target_dir.join(".git")).unwrap(); + + assert!(matches!( + Install::install_repos(&deps, &deps_dir, &mut Vec::new()), + Err(InstallError::Incomplete(path)) if path == target_dir + )); + assert!(target_dir.join(".git").is_dir(), "nothing is removed"); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn checkout_without_its_config_is_not_installed() { + let root = std::env::temp_dir().join(format!("smplx-install-no-config-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + + let repo = root.join("a"); + fs::create_dir_all(&repo).unwrap(); + fs::write(repo.join("README.md"), "no config").unwrap(); + git(&repo, &["init", "--quiet"]); + git(&repo, &["add", "."]); + git(&repo, &["commit", "--quiet", "-m", "init"]); + + let url = format!("file://{}", repo.display()); + let deps = DependencyConfig::from_source(&format!("[dependencies]\na = {{ git = \"{url}\" }}\n")).unwrap(); + let deps_dir = root.join("deps"); + fs::create_dir_all(&deps_dir).unwrap(); + + assert!(matches!( + Install::install_repos(&deps, &deps_dir, &mut Vec::new()), + Err(InstallError::MissingConfig(_)) + )); + assert_eq!(fs::read_dir(&deps_dir).unwrap().count(), 0); + + fs::remove_dir_all(root).unwrap(); + } + #[test] fn cyclic_git_dependencies_terminate() { let root = std::env::temp_dir().join(format!("smplx-install-cycle-{}", std::process::id())); diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index b78fadac..154dad6e 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod build; pub mod clean; pub mod core; pub mod error; +pub mod fmt; pub mod init; pub mod install; pub mod regtest; diff --git a/crates/cli/src/config/core.rs b/crates/cli/src/config/core.rs index dc582ec5..42160220 100644 --- a/crates/cli/src/config/core.rs +++ b/crates/cli/src/config/core.rs @@ -1,6 +1,8 @@ -use serde::Deserialize; +use std::ffi::OsStr; use std::path::{Path, PathBuf}; +use serde::Deserialize; + use smplx_build::{BuildConfig, CONFIG_FILENAME, DependencyConfig}; use smplx_regtest::RegtestConfig; use smplx_sdk::provider::SimplicityNetwork; @@ -29,7 +31,30 @@ impl Config { /// - If the current working directory cannot be determined. /// - If the `get_path` function encounters an error, processing the retrieved path. pub fn get_default_path() -> Result { - Self::get_path(std::env::current_dir()?) + Self::get_path(std::env::current_dir().map_err(ConfigError::CurrentDir)?) + } + + /// Finds the nearest `Simplex.toml` in the current directory or its ancestors. + /// + /// # Errors + /// Returns a [`ConfigError`] if the current directory cannot be determined + /// or no config is found. + pub fn get_ancestors_config_path() -> Result { + let current_dir = Self::get_default_path()?; + Self::resolve_config_path(¤t_dir) + } + + /// Returns the parent directory of the given config path. + /// + /// # Errors + /// Returns a [`ConfigError`] if the path has no parent directory. + pub fn get_project_root(config_path: impl AsRef) -> Result { + let config_path = config_path.as_ref(); + let root = config_path + .parent() + .ok_or_else(|| ConfigError::InvalidConfigPath(config_path.to_path_buf()))?; + + Ok(root.to_path_buf()) } /// Constructs a complete configuration file path by joining the provided path with the @@ -89,4 +114,68 @@ impl Config { Ok(()) } + + /// Resolves config path by moving through ancestors. + fn resolve_config_path(current_dir: impl AsRef) -> Result { + let current_dir = current_dir.as_ref(); + let config_path = { + let path = current_dir + .ancestors() + .map(|directory| directory.join(CONFIG_FILENAME)) + .find(|candidate| candidate.is_file()); + + if path.is_none() { + return Err(ConfigError::FailedToFindConfig); + } + + path.unwrap() + }; + + if config_path.file_name() != Some(OsStr::new(CONFIG_FILENAME)) { + return Err(ConfigError::InvalidConfigPath(config_path)); + } + + if config_path.is_absolute() { + Ok(config_path) + } else { + Ok(current_dir.join(config_path)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::path::Path; + + #[test] + fn discovers_config_in_current_directory() { + let project_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures"); + + let config_path = Config::resolve_config_path(&project_root).expect("config should be discovered"); + + assert_eq!(config_path, project_root.join(CONFIG_FILENAME)); + } + + #[test] + fn discovers_config_from_nested_directory() { + let project_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures"); + let nested_directory = project_root.join("simf/nested/nested_2/imp2"); + + let config_path = Config::resolve_config_path(&nested_directory).expect("config should be discovered"); + + assert_eq!(config_path, project_root.join(CONFIG_FILENAME)); + } + + #[test] + fn discovers_nearest_config() { + let fixtures_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures"); + let project_root = fixtures_root.join("deps/math"); + let nested_directory = project_root.join("simf"); + + let config_path = Config::resolve_config_path(&nested_directory).expect("config should be discovered"); + + assert_eq!(config_path, project_root.join(CONFIG_FILENAME)); + } } diff --git a/crates/cli/src/config/error.rs b/crates/cli/src/config/error.rs index 506fa9e4..b67ee5a1 100644 --- a/crates/cli/src/config/error.rs +++ b/crates/cli/src/config/error.rs @@ -34,4 +34,13 @@ pub enum ConfigError { #[error("Path doesn't exist: '{0}'")] PathNotExists(PathBuf), + + #[error("Failed to determine the current directory: {0}")] + CurrentDir(std::io::Error), + + #[error("Failed to find `Simplex.toml` in current and parent directories")] + FailedToFindConfig, + + #[error("The config-path must be a path to a Simplex.toml file, got: '{}'", .0.display())] + InvalidConfigPath(PathBuf), } diff --git a/crates/macros/Cargo.toml b/crates/macros/Cargo.toml index c9c02213..c9b66e19 100644 --- a/crates/macros/Cargo.toml +++ b/crates/macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smplx-macros" -version = "0.1.1" +version = "0.2.0" description = "Simplex macros re-export package" license.workspace = true edition.workspace = true diff --git a/crates/regtest/Cargo.toml b/crates/regtest/Cargo.toml index 98bb826e..166d9c72 100644 --- a/crates/regtest/Cargo.toml +++ b/crates/regtest/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smplx-regtest" -version = "0.1.1" +version = "0.2.0" description = "Simplex regtest command internal implementation" license.workspace = true edition.workspace = true diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index 1d3c9196..877821cd 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smplx-sdk" -version = "0.1.1" +version = "0.2.0" description = "Simplex sdk to simplify the development with simplicity" license.workspace = true edition.workspace = true @@ -16,7 +16,6 @@ provider = ["dep:minreq", "dep:bitcoincore-rpc"] [dependencies] thiserror = { workspace = true } -sha2 = { workspace = true } minreq = { workspace = true, optional = true } simplicityhl = { workspace = true } bitcoincore-rpc = { workspace = true, optional = true } diff --git a/crates/sdk/src/constants.rs b/crates/sdk/src/constants.rs index add3f2dd..cd5d7044 100644 --- a/crates/sdk/src/constants.rs +++ b/crates/sdk/src/constants.rs @@ -1,11 +1,13 @@ -/// General public blinder key to use -pub const PUBLIC_SECRET_BLINDER_KEY: [u8; 32] = [1; 32]; /// Dummy signature, which is used for fee estimation pub const DUMMY_SIGNATURE: [u8; 64] = [1; 64]; -/// Minimal acceptable fee for nodes to send a transaction +/// Minimal acceptable fee for nodes to relay a transaction pub const MIN_FEE: u64 = 10; +/// The smallest explicit output Elements relays by default for any standard script. +/// 14 sats for P2WPKH, 15 for P2TR, 22 for P2PKH and P2SH. +pub const DUST_THRESHOLD: u64 = 22; + /// Policy `AssetId` (hex, BE) for Liquid mainnet. pub const LIQUID_POLICY_ASSET_STR: &str = "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d"; @@ -14,6 +16,3 @@ pub const LIQUID_TESTNET_POLICY_ASSET_STR: &str = "144c654344aa716d6f3abcc1ca90e /// Policy `AssetId` (hex, BE) for Elements regtest. pub const LIQUID_DEFAULT_REGTEST_ASSET_STR: &str = "5ac9f65c0efcc4775e0baec4ec03abdde22473cd3cf33c0419ca290e0751b225"; - -/// Example test `AssetId` (hex, BE) on Liquid testnet. -pub const LIQUID_TESTNET_TEST_ASSET_ID_STR: &str = "38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5"; diff --git a/crates/sdk/src/program/core.rs b/crates/sdk/src/program/core.rs index 87e394e1..722a2324 100644 --- a/crates/sdk/src/program/core.rs +++ b/crates/sdk/src/program/core.rs @@ -8,7 +8,7 @@ use simplicityhl::elements::{Address, Script, Transaction, TxOut, taproot}; use simplicityhl::num::U256; use simplicityhl::simplicity::bitcoin::{XOnlyPublicKey, secp256k1}; use simplicityhl::simplicity::jet::elements::{ElementsEnv, ElementsUtxo}; -use simplicityhl::simplicity::{BitMachine, RedeemNode, Value, leaf_version}; +use simplicityhl::simplicity::{BitMachine, Cmr, RedeemNode, Value, leaf_version}; use simplicityhl::{Arguments, Parameters, ResolvedType, TemplateProgramWitness, WitnessTypes, WitnessValues}; use simplicityhl::{CompiledProgram, UnstableFeatures}; @@ -23,7 +23,7 @@ use crate::utils::{check_budget, hash_script, tap_data_hash, tr_unspendable_key} /// Executes `simplicity` programs at runtime. /// /// This trait defines a core behavior related to testing and execution. -pub trait ProgramTrait: DynClone { +pub trait ProgramTrait: DynClone + Send + Sync { /// Retrieves the types of arguments required by a `simplicity` program. /// /// # Errors @@ -82,6 +82,12 @@ pub trait ProgramTrait: DynClone { /// Static description of a program's inputs and integer literals. Implemented by `include_simf!()`. pub trait ProgramSchema { + /// Generated arguments struct. + type Arguments; + + /// Generated witness struct. + type Witness; + /// Parameter names and types. fn argument_types() -> Vec<(TemplateProgramWitness, ResolvedType)>; @@ -101,7 +107,7 @@ pub trait ProgramFactory + Sized> { } /// Represents a program structure containing its public key, compiled program, and associated storage. -/// A compiled program acts as a cache, instantiated during "loading". +/// The compiled program with its CMR and the taproot spend info act as caches, filled on first use. /// /// Abstraction giving the power to execute Simplicity contracts without specifying any additional parameters. #[derive(Clone)] @@ -111,7 +117,8 @@ pub struct Program { pub_key: XOnlyPublicKey, storage: Vec>, include_debug_symbols: Option, - compiled: Arc>, + compiled: Arc>, + spend_info: OnceLock, } dyn_clone::clone_trait_object!(ProgramTrait); @@ -132,8 +139,18 @@ impl ProgramTrait for Program { network: &SimplicityNetwork, ) -> Result>, ProgramError> { let genesis_hash = network.genesis_block_hash(); - let cmr = self.load()?.commit().cmr(); - let utxos: Vec = pst.inputs().iter().filter_map(|x| x.witness_utxo.clone()).collect(); + let cmr = self.cmr()?; + let utxos: Vec = pst + .inputs() + .iter() + .enumerate() + .map(|(input_index, input)| { + input + .witness_utxo + .clone() + .ok_or(ProgramError::MissingWitnessUtxo { input_index }) + }) + .collect::>()?; if utxos.len() <= input_index { return Err(ProgramError::UtxoIndexOutOfBounds { @@ -270,6 +287,9 @@ impl Program { /// The width of a storage slot. pub const STORAGE_SLOT_BYTES: usize = 32; + /// Each slot deepens the taproot tree by one and the tree is at most 128 layers deep. + pub const MAX_STORAGE_SLOTS: usize = 128; + /// Creates a new instance of the struct with the provided source string and arguments. #[must_use] pub fn new(source: impl Into>, arguments: impl Into) -> Self { @@ -280,6 +300,7 @@ impl Program { storage: Vec::new(), include_debug_symbols: None, compiled: Arc::new(OnceLock::new()), + spend_info: OnceLock::new(), } } @@ -288,6 +309,7 @@ impl Program { #[must_use] pub fn with_taproot_pubkey(mut self, pub_key: XOnlyPublicKey) -> Self { self.pub_key = pub_key; + self.spend_info = OnceLock::new(); self } @@ -298,18 +320,42 @@ impl Program { 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.spend_info = OnceLock::new(); self } /// Sets storage capacity for further usage. + /// + /// # Panics + /// Panics if `capacity` exceeds `MAX_STORAGE_SLOTS`. #[must_use] pub fn with_storage_capacity(mut self, capacity: usize) -> Self { - self.storage = vec![vec![0u8; 32]; capacity]; + Self::check_storage_capacity(capacity); + + self.storage = vec![vec![0u8; Self::STORAGE_SLOT_BYTES]; capacity]; + self.spend_info = OnceLock::new(); self } + /// Replaces the storage with `storage`, one value per slot. + /// + /// # Panics + /// Panics if a value is not `STORAGE_SLOT_BYTES` wide, or if there are more than `MAX_STORAGE_SLOTS`. + pub fn set_storage(&mut self, storage: impl IntoIterator>>) { + let storage: Vec> = storage.into_iter().map(Into::into).collect(); + + Self::check_storage_capacity(storage.len()); + + self.storage = vec![vec![0u8; Self::STORAGE_SLOT_BYTES]; storage.len()]; + self.spend_info = OnceLock::new(); + + for (index, value) in storage.into_iter().enumerate() { + self.set_storage_at(index, value); + } + } + /// Sets a 32-byte value at the specified index in the storage. /// /// # Panics @@ -328,6 +374,13 @@ impl Program { let slot = self.storage.get_mut(index).expect("Index out of bounds"); *slot = value; + self.spend_info = OnceLock::new(); + } + + /// Returns the arguments the program was created with. + #[must_use] + pub fn get_arguments(&self) -> &Arguments { + &self.arguments } /// Returns the number of storage chunks for a program. @@ -357,7 +410,7 @@ impl Program { /// Panics if generating the taproot spending information fails. #[must_use] pub fn get_tr_address(&self, network: &SimplicityNetwork) -> Address { - let spend_info = self.taproot_spending_info().unwrap(); + let spend_info = self.taproot_spending_info().unwrap_or_else(|error| panic!("{error}")); Address::p2tr( secp256k1::SECP256K1, @@ -386,7 +439,7 @@ impl Program { /// Panics if the `SimplicityHL` compilation fails. #[must_use] pub fn get_cmr(&self) -> [u8; 32] { - self.load().unwrap().commit().cmr().to_byte_array() + self.cmr().unwrap_or_else(|error| panic!("{error}")).to_byte_array() } /// Returns the 32-byte tapleaf hash of the program's Simplicity script. @@ -395,7 +448,7 @@ impl Program { /// Panics if the `SimplicityHL` compilation fails. #[must_use] pub fn get_tapleaf_hash(&self) -> [u8; 32] { - let (script, version) = self.script_version().unwrap(); + let (script, version) = self.script_version().unwrap_or_else(|error| panic!("{error}")); taproot::TapLeafHash::from_script(&script, version).to_byte_array() } @@ -413,17 +466,12 @@ impl Program { Ok(abi_meta.param_types) } - /// Retrieves the witness types from the compiled program's ABI metadata. + /// Retrieves the witness types of the compiled program. /// /// # Errors - /// Returns a `ProgramError` if compilation fails or generating ABI metadata fails. + /// Returns a `ProgramError` if compilation fails. pub fn get_witness_types(&self) -> Result { - let abi_meta = self - .load()? - .generate_abi_meta() - .map_err(ProgramError::ProgramGenAbiMeta)?; - - Ok(abi_meta.witness_types) + Ok(self.load()?.witness_types().shallow_clone()) } /// Compiles program by providing saved `Arguments`. @@ -431,9 +479,17 @@ impl Program { /// # Errors /// Retruns an error we have problems in a program compilation. pub fn load(&self) -> Result<&CompiledProgram, ProgramError> { + Ok(&self.load_with_cmr()?.0) + } + + fn cmr(&self) -> Result { + Ok(self.load_with_cmr()?.1) + } + + fn load_with_cmr(&self) -> Result<&(CompiledProgram, Cmr), ProgramError> { // Check cache first - if let Some(compiled) = self.compiled.get() { - return Ok(compiled); + if let Some(loaded) = self.compiled.get() { + return Ok(loaded); } let compiled = CompiledProgram::new_with_unstable( @@ -445,22 +501,19 @@ impl Program { Box::new(ElementsJetHinter), ) .map_err(ProgramError::Compilation)?; + let cmr = compiled.commit().cmr(); // Update the cache - Ok(self.compiled.get_or_init(|| compiled)) + Ok(self.compiled.get_or_init(|| (compiled, cmr))) } fn script_version(&self) -> Result<(Script, taproot::LeafVersion), ProgramError> { - let cmr = self.load()?.commit().cmr(); + let cmr = self.cmr()?; let script = Script::from(cmr.as_ref().to_vec()); 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"); @@ -473,7 +526,11 @@ impl Program { depths } - fn taproot_spending_info(&self) -> Result { + fn taproot_spending_info(&self) -> Result<&taproot::TaprootSpendInfo, ProgramError> { + if let Some(spend_info) = self.spend_info.get() { + return Ok(spend_info); + } + let mut builder = taproot::TaprootBuilder::new(); let (script, version) = self.script_version()?; let depths = Self::taproot_leaf_depths(1 + self.get_storage_len()); @@ -491,9 +548,11 @@ impl Program { .expect("tap tree should be valid"); } - Ok(builder + let spend_info = builder .finalize(secp256k1::SECP256K1, self.pub_key) - .expect("tap tree should be valid")) + .expect("tap tree should be valid"); + + Ok(self.spend_info.get_or_init(|| spend_info)) } fn control_block(&self) -> Result { @@ -502,6 +561,14 @@ impl Program { Ok(info.control_block(&script_ver).expect("control block should exist")) } + + fn check_storage_capacity(capacity: usize) { + assert!( + capacity <= Self::MAX_STORAGE_SLOTS, + "A program holds at most {} storage slots, and this one asks for {capacity}", + Self::MAX_STORAGE_SLOTS + ); + } } #[cfg(test)] @@ -552,15 +619,15 @@ mod tests { #[test] fn changing_a_slot_changes_the_address_but_not_the_program() { - let mut one = stateful(1); - let mut other = stateful(1); + let one = stateful(1); + let network = SimplicityNetwork::LiquidTestnet; + let address = one.get_tr_address(&network); - one.set_storage_at(0, vec![0x00; Program::STORAGE_SLOT_BYTES]); + let mut other = one.clone(); other.set_storage_at(0, vec![0x01; Program::STORAGE_SLOT_BYTES]); - let network = SimplicityNetwork::LiquidTestnet; - - assert_ne!(one.get_tr_address(&network), other.get_tr_address(&network)); + assert_ne!(address, other.get_tr_address(&network)); + assert_eq!(address, one.get_tr_address(&network)); assert_eq!(one.get_cmr(), other.get_cmr()); assert_eq!(one.get_tapleaf_hash(), other.get_tapleaf_hash()); } @@ -663,6 +730,40 @@ mod tests { assert!(program.get_env(&pst, 1, &network).is_ok()); } + #[test] + fn input_without_its_utxo_is_an_error() { + let network = SimplicityNetwork::default_regtest(); + let program = stateful(0); + let mut pst = PartiallySignedTransaction::new_v2(); + + pst.add_input(Input::default()); + pst.add_input(Input { + witness_utxo: Some(TxOut { + script_pubkey: program.get_script_pubkey(&network), + ..Default::default() + }), + ..Default::default() + }); + + assert!(matches!( + program.get_env(&pst, 1, &network).unwrap_err(), + ProgramError::MissingWitnessUtxo { input_index: 0 } + )); + } + + #[test] + fn storage_is_at_most_as_deep_as_the_taproot_tree() { + let network = SimplicityNetwork::default_regtest(); + + let _ = stateful(Program::MAX_STORAGE_SLOTS).get_tr_address(&network); + } + + #[test] + #[should_panic(expected = "A program holds at most 128 storage slots, and this one asks for 129")] + fn storage_beyond_the_taproot_tree_is_refused() { + let _ = stateful(Program::MAX_STORAGE_SLOTS + 1); + } + #[test] fn left_folded_depths() { assert_eq!(Program::taproot_leaf_depths(1), vec![0]); diff --git a/crates/sdk/src/program/error.rs b/crates/sdk/src/program/error.rs index 65f77523..63252324 100644 --- a/crates/sdk/src/program/error.rs +++ b/crates/sdk/src/program/error.rs @@ -43,6 +43,13 @@ pub enum ProgramError { #[error("Failed to execute program on the Bit Machine: {0}")] Execution(simplicityhl::simplicity::bit_machine::ExecutionError), + /// Error indicating an input that doesn't carry the output it spends. + #[error("Input {input_index} has no witness UTXO")] + MissingWitnessUtxo { + /// The index of the input without one. + input_index: usize, + }, + /// Error indicating an input index points past the bounds of available inputs/UTXOs. #[error("UTXO index {input_index} out of bounds (have {utxo_count} UTXOs)")] UtxoIndexOutOfBounds { diff --git a/crates/sdk/src/provider/core.rs b/crates/sdk/src/provider/core.rs index 11b7f958..e0f5e6f9 100644 --- a/crates/sdk/src/provider/core.rs +++ b/crates/sdk/src/provider/core.rs @@ -14,6 +14,8 @@ use super::error::ProviderError; pub const DEFAULT_FEE_RATE: f32 = 100.0; /// The standard timeout duration (in seconds) applied to Esplora REST API requests. pub const DEFAULT_ESPLORA_TIMEOUT_SECS: u64 = 10; +/// How many times `EsploraProvider::wait` polls for a confirmation by default. +pub const DEFAULT_CONFIRMATION_ATTEMPTS: u32 = 9; /// Contains foundational configuration elements required for initializing a generic blockchain provider. #[cfg(feature = "provider")] @@ -28,7 +30,7 @@ pub struct ProviderInfo { } /// Baseline traits detailing required interaction methods between the SDK client and the underlying blockchain node or API. -pub trait ProviderTrait { +pub trait ProviderTrait: Send + Sync { /// Retrieves the network configured for this provider. fn get_network(&self) -> &SimplicityNetwork; diff --git a/crates/sdk/src/provider/error.rs b/crates/sdk/src/provider/error.rs index 0c47616e..145943ce 100644 --- a/crates/sdk/src/provider/error.rs +++ b/crates/sdk/src/provider/error.rs @@ -15,7 +15,7 @@ pub enum ProviderError { /// Error indicating the configured timeout to wait for transaction confirmation elapsed without confirmation. #[error("Couldn't wait for the transaction to be confirmed")] - Confirmation(), + Confirmation, /// Error indicating a provider returned a non-success response explicitly rejecting a broadcasted transaction payload. #[error("Broadcast failed with HTTP {status} for {url}: {message}")] @@ -28,6 +28,19 @@ pub enum ProviderError { message: String, }, + /// Error indicating the provider answered with another transaction. + #[error("Expected transaction {expected}, the provider returned {returned}")] + UnexpectedTxid { + /// The transaction asked for or broadcast. + expected: simplicityhl::elements::Txid, + /// The transaction the provider returned. + returned: simplicityhl::elements::Txid, + }, + + /// Error indicating the provider listed an output that isn't locked by the queried script. + #[error("The provider listed {0}, which the queried script doesn't lock")] + UnexpectedScript(simplicityhl::elements::OutPoint), + /// Error indicating that a provider's raw response body was unable to be correctly deserialized into native structs or types. #[error("Failed to deserialize response: {0}")] Deserialize(String), @@ -38,7 +51,7 @@ pub enum ProviderError { /// Error indicating that the rpc returned a malformed response. #[error("Couldn't parse the response")] - BadResponse(), + BadResponse, } /// Network conversion error. diff --git a/crates/sdk/src/provider/esplora.rs b/crates/sdk/src/provider/esplora.rs index e8244377..6d7c5237 100644 --- a/crates/sdk/src/provider/esplora.rs +++ b/crates/sdk/src/provider/esplora.rs @@ -1,19 +1,19 @@ use std::collections::HashMap; -use std::collections::HashSet; +use std::collections::hash_map::Entry; use std::str::FromStr; use std::time::Duration; -use simplicityhl::elements::hashes::sha256; - use simplicityhl::elements::encode; -use simplicityhl::elements::{Address, OutPoint, Script, Transaction, Txid}; +use simplicityhl::elements::{Address, AssetId, OutPoint, Script, Transaction, Txid}; use serde::Deserialize; +use serde::de::DeserializeOwned; use crate::provider::SimplicityNetwork; use crate::transaction::{TxReceipt, UTXO}; +use crate::utils::hash_script; -use super::core::{DEFAULT_ESPLORA_TIMEOUT_SECS, ProviderTrait}; +use super::core::{DEFAULT_CONFIRMATION_ATTEMPTS, DEFAULT_ESPLORA_TIMEOUT_SECS, ProviderTrait}; use super::error::ProviderError; /// A provider implementation that interacts with the Esplora REST API backend. @@ -25,43 +25,26 @@ pub struct EsploraProvider { pub network: SimplicityNetwork, /// Timeout duration used in underlying HTTP requests. pub timeout: Duration, + /// How many times `wait` polls for a confirmation before giving up. + pub confirmation_attempts: u32, } #[derive(Deserialize)] -#[allow(dead_code)] struct TxStatus { confirmed: bool, - block_height: Option, } #[derive(Deserialize)] -#[allow(dead_code)] struct EsploraBlock { - id: String, - height: u32, timestamp: u64, - tx_count: u32, -} - -#[derive(Clone, Deserialize)] -#[allow(dead_code)] -struct UtxoStatus { - pub confirmed: bool, - pub block_height: Option, - pub block_hash: Option, - pub block_time: Option, } -#[derive(Clone, Deserialize)] -#[allow(dead_code)] +#[derive(Deserialize)] struct EsploraUtxo { - pub txid: String, - pub vout: u32, - pub value: Option, - pub valuecommitment: Option, - pub asset: Option, - pub assetcommitment: Option, - pub status: UtxoStatus, + txid: String, + vout: u32, + value: Option, + asset: Option, } impl EsploraProvider { @@ -72,43 +55,84 @@ impl EsploraProvider { esplora_url: url, network, timeout: Duration::from_secs(DEFAULT_ESPLORA_TIMEOUT_SECS), + confirmation_attempts: DEFAULT_CONFIRMATION_ATTEMPTS, } } - fn esplora_utxo_to_outpoint(utxo: &EsploraUtxo) -> Result { - let txid = Txid::from_str(&utxo.txid).map_err(|e| ProviderError::InvalidTxid(e.to_string()))?; - - Ok(OutPoint::new(txid, utxo.vout)) + /// Sends a GET to `path` under the Esplora URL, whatever the status it answers with. + fn request(&self, path: &str) -> Result { + minreq::get(format!("{}{path}", self.esplora_url)) + .with_timeout(self.timeout.as_secs()) + .send() + .map_err(|e| ProviderError::Request(e.to_string())) } - fn populate_txouts_from_outpoints(&self, outpoints: &[OutPoint]) -> Result, ProviderError> { - let set: HashSet<_> = outpoints.iter().collect(); - let mut map = HashMap::new(); + /// Sends a GET to `path`, failing unless it answers 200. + fn get(&self, path: &str) -> Result { + let response = self.request(path)?; - // filter unique transactions - for point in set { - let tx = self.fetch_transaction(&point.txid)?; - map.insert(point.txid, tx); + if response.status_code != 200 { + return Err(ProviderError::Request(format!( + "HTTP {}: {}", + response.status_code, response.reason_phrase + ))); } - let mut utxos = Vec::with_capacity(outpoints.len()); + Ok(response) + } - for point in outpoints { - let txout = map - .get(&point.txid) - .unwrap() - .output - .get(point.vout as usize) - .ok_or_else(ProviderError::BadResponse)?; + fn get_text(&self, path: &str) -> Result { + let response = self.get(path)?; + let text = response + .as_str() + .map_err(|e| ProviderError::Deserialize(e.to_string()))?; - utxos.push(UTXO { - outpoint: *point, - txout: txout.clone(), - secrets: None, - }); - } + Ok(text.trim().to_owned()) + } + + fn get_json(&self, path: &str) -> Result { + self.get(path)? + .json() + .map_err(|e| ProviderError::Deserialize(e.to_string())) + } + + /// The UTXOs of `utxos`, all locked by `script`. Explicit ones are built from the response alone. + /// Confidential ones need their transaction for the nonce and range proof. + fn to_utxos(&self, utxos: &[EsploraUtxo], script: &Script) -> Result, ProviderError> { + let mut transactions: HashMap = HashMap::new(); - Ok(utxos) + utxos + .iter() + .map(|utxo| { + let txid = Txid::from_str(&utxo.txid).map_err(|e| ProviderError::InvalidTxid(e.to_string()))?; + let outpoint = OutPoint::new(txid, utxo.vout); + + if let (Some(value), Some(asset)) = (utxo.value, &utxo.asset) { + let asset = AssetId::from_str(asset).map_err(|e| ProviderError::Deserialize(e.to_string()))?; + + return Ok(UTXO::explicit(outpoint, script.clone(), value, asset)); + } + + if let Entry::Vacant(entry) = transactions.entry(txid) { + entry.insert(self.fetch_transaction(&txid)?); + } + + let txout = transactions[&txid] + .output + .get(utxo.vout as usize) + .ok_or(ProviderError::BadResponse)?; + + if txout.script_pubkey != *script { + return Err(ProviderError::UnexpectedScript(outpoint)); + } + + Ok(UTXO { + outpoint, + txout: txout.clone(), + secrets: None, + }) + }) + .collect() } } @@ -135,263 +159,103 @@ impl ProviderTrait for EsploraProvider { if !(200..300).contains(&status) { return Err(ProviderError::BroadcastRejected { status: status as u16, - url: format!("{}/tx", self.esplora_url), + url, message: body, }); } - Txid::from_str(&body) - .map_err(|e| ProviderError::InvalidTxid(e.to_string())) - .map(|tx_id| TxReceipt::new(self, tx_id)) + let returned = Txid::from_str(&body).map_err(|e| ProviderError::InvalidTxid(e.to_string()))?; + + if returned != tx.txid() { + return Err(ProviderError::UnexpectedTxid { + expected: tx.txid(), + returned, + }); + } + + Ok(TxReceipt::new(self, returned)) } fn wait(&self, txid: &Txid) -> Result<(), ProviderError> { - let url = format!("{}/tx/{}/status", self.esplora_url, txid); - let timeout_secs = self.timeout.as_secs(); + let path = format!("/tx/{txid}/status"); let confirmation_poll = match self.network { SimplicityNetwork::ElementsRegtest { .. } => Duration::from_millis(100), _ => Duration::from_secs(10), }; - // polling needs to be > 1 min on mainnet/testnet - for _ in 1..10 { - let response = minreq::get(&url) - .with_timeout(timeout_secs) - .send() - .map_err(|e| ProviderError::Request(e.to_string()))?; - - if response.status_code != 200 { - std::thread::sleep(confirmation_poll); - continue; - } + for _ in 0..self.confirmation_attempts { + let response = self.request(&path)?; - let status: TxStatus = response.json().map_err(|e| ProviderError::Deserialize(e.to_string()))?; + // an unknown transaction answers 404 until Esplora indexes it + if response.status_code == 200 { + let status: TxStatus = response.json().map_err(|e| ProviderError::Deserialize(e.to_string()))?; - if status.confirmed { - return Ok(()); + if status.confirmed { + return Ok(()); + } } std::thread::sleep(confirmation_poll); } - Err(ProviderError::Confirmation()) + Err(ProviderError::Confirmation) } fn fetch_tip_height(&self) -> Result { - let url = format!("{}/blocks/tip/height", self.esplora_url); - let timeout_secs = self.timeout.as_secs(); - - let response = minreq::get(&url) - .with_timeout(timeout_secs) - .send() - .map_err(|e| ProviderError::Request(e.to_string()))?; - - if response.status_code != 200 { - return Err(ProviderError::Request(format!( - "HTTP {}: {}", - response.status_code, response.reason_phrase - ))); - } - - let body_str = response - .as_str() - .map_err(|e| ProviderError::Deserialize(e.to_string()))?; - - let height: u32 = body_str - .trim() + self.get_text("/blocks/tip/height")? .parse() - .map_err(|e: std::num::ParseIntError| ProviderError::Deserialize(e.to_string()))?; - - Ok(height) + .map_err(|e: std::num::ParseIntError| ProviderError::Deserialize(e.to_string())) } fn fetch_tip_block_hash(&self) -> Result { - let url = format!("{}/blocks/tip/hash", self.esplora_url); - let timeout_secs = self.timeout.as_secs(); - - let response = minreq::get(&url) - .with_timeout(timeout_secs) - .send() - .map_err(|e| ProviderError::Request(e.to_string()))?; - - if response.status_code != 200 { - return Err(ProviderError::Request(format!( - "HTTP {}: {}", - response.status_code, response.reason_phrase - ))); - } - - let tip_block_hash = response - .as_str() - .map_err(|e| ProviderError::Deserialize(e.to_string()))? - .trim(); - - Ok(tip_block_hash.into()) + self.get_text("/blocks/tip/hash") } fn fetch_tip_timestamp(&self) -> Result { let tip_hash = self.fetch_tip_block_hash()?; - - let url = format!("{}/block/{}", self.esplora_url, tip_hash); - let timeout_secs = self.timeout.as_secs(); - - let response = minreq::get(&url) - .with_timeout(timeout_secs) - .send() - .map_err(|e| ProviderError::Request(e.to_string()))?; - - if response.status_code != 200 { - return Err(ProviderError::Request(format!( - "HTTP {}: {}", - response.status_code, response.reason_phrase - ))); - } - - let block: EsploraBlock = response.json().map_err(|e| ProviderError::Deserialize(e.to_string()))?; + let block: EsploraBlock = self.get_json(&format!("/block/{tip_hash}"))?; Ok(block.timestamp) } fn fetch_block_hash_at_height(&self, block_height: u32) -> Result { - let url = format!("{}/block-height/{}", self.esplora_url, block_height); - let timeout_secs = self.timeout.as_secs(); - - let response = minreq::get(&url) - .with_timeout(timeout_secs) - .send() - .map_err(|e| ProviderError::Request(e.to_string()))?; - - if response.status_code != 200 { - return Err(ProviderError::Request(format!( - "HTTP {}: {}", - response.status_code, response.reason_phrase - ))); - } - - let block_hash = response - .as_str() - .map_err(|e| ProviderError::Deserialize(e.to_string()))? - .trim(); - - Ok(block_hash.into()) + self.get_text(&format!("/block-height/{block_height}")) } fn fetch_block_txids(&self, block_hash: &str) -> Result, ProviderError> { - let url = format!("{}/block/{}/txids", self.esplora_url, block_hash); - let timeout_secs = self.timeout.as_secs(); - - let response = minreq::get(&url) - .with_timeout(timeout_secs) - .send() - .map_err(|e| ProviderError::Request(e.to_string()))?; - - if response.status_code != 200 { - return Err(ProviderError::Request(format!( - "HTTP {}: {}", - response.status_code, response.reason_phrase - ))); - } - - let block_txids: Vec = response.json().map_err(|e| ProviderError::Deserialize(e.to_string()))?; - - Ok(block_txids) + self.get_json(&format!("/block/{block_hash}/txids")) } fn fetch_transaction(&self, txid: &Txid) -> Result { - let url = format!("{}/tx/{}/raw", self.esplora_url, txid); - let timeout_secs = self.timeout.as_secs(); - - let response = minreq::get(&url) - .with_timeout(timeout_secs) - .send() - .map_err(|e| ProviderError::Request(e.to_string()))?; - - if response.status_code != 200 { - return Err(ProviderError::Request(format!( - "HTTP {}: {}", - response.status_code, response.reason_phrase - ))); + let response = self.get(&format!("/tx/{txid}/raw"))?; + let transaction: Transaction = + encode::deserialize(response.as_bytes()).map_err(|e| ProviderError::Deserialize(e.to_string()))?; + + if transaction.txid() != *txid { + return Err(ProviderError::UnexpectedTxid { + expected: *txid, + returned: transaction.txid(), + }); } - let bytes = response.as_bytes(); - let tx: Transaction = encode::deserialize(bytes).map_err(|e| ProviderError::Deserialize(e.to_string()))?; - - Ok(tx) + Ok(transaction) } fn fetch_address_utxos(&self, address: &Address) -> Result, ProviderError> { - let url = format!("{}/address/{}/utxo", self.esplora_url, address); - let timeout_secs = self.timeout.as_secs(); + let utxos: Vec = self.get_json(&format!("/address/{address}/utxo"))?; - let response = minreq::get(&url) - .with_timeout(timeout_secs) - .send() - .map_err(|e| ProviderError::Request(e.to_string()))?; - - if response.status_code != 200 { - return Err(ProviderError::Request(format!( - "HTTP {}: {}", - response.status_code, response.reason_phrase - ))); - } - - let utxos: Vec = response.json().map_err(|e| ProviderError::Deserialize(e.to_string()))?; - let outpoints = utxos - .iter() - .map(Self::esplora_utxo_to_outpoint) - .collect::, ProviderError>>()?; - - self.populate_txouts_from_outpoints(&outpoints) + self.to_utxos(&utxos, &address.script_pubkey()) } fn fetch_scripthash_utxos(&self, script: &Script) -> Result, ProviderError> { - let hash = sha256::Hash::hash(script.as_bytes()); - let hash_bytes = hash.to_byte_array(); - let scripthash = hex::encode(hash_bytes); + let scripthash = hex::encode(hash_script(script)); + let utxos: Vec = self.get_json(&format!("/scripthash/{scripthash}/utxo"))?; - let url = format!("{}/scripthash/{}/utxo", self.esplora_url, scripthash); - let timeout_secs = self.timeout.as_secs(); - - let response = minreq::get(&url) - .with_timeout(timeout_secs) - .send() - .map_err(|e| ProviderError::Request(e.to_string()))?; - - if response.status_code != 200 { - return Err(ProviderError::Request(format!( - "HTTP {}: {}", - response.status_code, response.reason_phrase - ))); - } - - let utxos: Vec = response.json().map_err(|e| ProviderError::Deserialize(e.to_string()))?; - let outpoints = utxos - .iter() - .map(Self::esplora_utxo_to_outpoint) - .collect::, ProviderError>>()?; - - self.populate_txouts_from_outpoints(&outpoints) + self.to_utxos(&utxos, script) } fn fetch_fee_estimates(&self) -> Result, ProviderError> { - let url = format!("{}/fee-estimates", self.esplora_url); - let timeout_secs = self.timeout.as_secs(); - - let response = minreq::get(&url) - .with_timeout(timeout_secs) - .send() - .map_err(|e| ProviderError::Request(e.to_string()))?; - - if response.status_code != 200 { - return Err(ProviderError::Request(format!( - "HTTP {}: {}", - response.status_code, response.reason_phrase - ))); - } - - let estimates: HashMap = response.json().map_err(|e| ProviderError::Deserialize(e.to_string()))?; - - Ok(estimates) + self.get_json("/fee-estimates") } } diff --git a/crates/sdk/src/provider/network.rs b/crates/sdk/src/provider/network.rs index 915580a1..b2a1ae4a 100644 --- a/crates/sdk/src/provider/network.rs +++ b/crates/sdk/src/provider/network.rs @@ -1,4 +1,5 @@ use std::str::FromStr; +use std::sync::LazyLock; use simplicityhl::simplicity::elements; @@ -7,16 +8,22 @@ use elements_miniscript::bitcoin::NetworkKind; use crate::constants::{LIQUID_DEFAULT_REGTEST_ASSET_STR, LIQUID_POLICY_ASSET_STR, LIQUID_TESTNET_POLICY_ASSET_STR}; use crate::provider::error::NetworkConvertError; -/// The default Bitcoin `AssetId` used on Liquid testnet. -pub static LIQUID_TESTNET_BITCOIN_ASSET: std::sync::LazyLock = std::sync::LazyLock::new(|| { - elements::AssetId::from_byte_array([ - 0x49, 0x9a, 0x81, 0x85, 0x45, 0xf6, 0xba, 0xe3, 0x9f, 0xc0, 0x3b, 0x63, 0x7f, 0x2a, 0x4e, 0x1e, 0x64, 0xe5, - 0x90, 0xca, 0xc1, 0xbc, 0x3a, 0x6f, 0x6d, 0x71, 0xaa, 0x44, 0x43, 0x65, 0x4c, 0x14, - ]) +/// The policy `AssetId` of Liquid mainnet. +pub static LIQUID_POLICY_ASSET: LazyLock = + LazyLock::new(|| elements::AssetId::from_str(LIQUID_POLICY_ASSET_STR).expect("the policy asset constant parses")); + +/// The policy `AssetId` of Liquid testnet. +pub static LIQUID_TESTNET_POLICY_ASSET: LazyLock = LazyLock::new(|| { + elements::AssetId::from_str(LIQUID_TESTNET_POLICY_ASSET_STR).expect("the policy asset constant parses") +}); + +/// The default policy `AssetId` of Elements regtest. +pub static LIQUID_REGTEST_POLICY_ASSET: LazyLock = LazyLock::new(|| { + elements::AssetId::from_str(LIQUID_DEFAULT_REGTEST_ASSET_STR).expect("the regtest asset constant parses") }); /// The genesis block hash for Liquid mainnet. -pub static LIQUID_MAINNET_GENESIS: std::sync::LazyLock = std::sync::LazyLock::new(|| { +pub static LIQUID_MAINNET_GENESIS: LazyLock = LazyLock::new(|| { elements::BlockHash::from_byte_array([ 0x03, 0x60, 0x20, 0x8a, 0x88, 0x96, 0x92, 0x37, 0x2c, 0x8d, 0x68, 0xb0, 0x84, 0xa6, 0x2e, 0xfd, 0xf6, 0x0e, 0xa1, 0xa3, 0x59, 0xa0, 0x4c, 0x94, 0xb2, 0x0d, 0x22, 0x36, 0x58, 0x27, 0x66, 0x14, @@ -24,7 +31,7 @@ pub static LIQUID_MAINNET_GENESIS: std::sync::LazyLock = st }); /// The genesis block hash for Liquid testnet. -pub static LIQUID_TESTNET_GENESIS: std::sync::LazyLock = std::sync::LazyLock::new(|| { +pub static LIQUID_TESTNET_GENESIS: LazyLock = LazyLock::new(|| { elements::BlockHash::from_byte_array([ 0xc1, 0xb1, 0x6a, 0xe2, 0x4f, 0x24, 0x23, 0xae, 0xa2, 0xea, 0x34, 0x55, 0x22, 0x92, 0x79, 0x3b, 0x5b, 0x5e, 0x82, 0x99, 0x9a, 0x1e, 0xed, 0x81, 0xd5, 0x6a, 0xee, 0x52, 0x8e, 0xda, 0x71, 0xa7, @@ -32,7 +39,7 @@ pub static LIQUID_TESTNET_GENESIS: std::sync::LazyLock = st }); /// The genesis block hash for Elements regtest environments. -pub static LIQUID_REGTEST_GENESIS: std::sync::LazyLock = std::sync::LazyLock::new(|| { +pub static LIQUID_REGTEST_GENESIS: LazyLock = LazyLock::new(|| { elements::BlockHash::from_byte_array([ 0x21, 0xca, 0xb1, 0xe5, 0xda, 0x47, 0x18, 0xea, 0x14, 0x0d, 0x97, 0x16, 0x93, 0x17, 0x02, 0x42, 0x2f, 0x0e, 0x6a, 0xd9, 0x15, 0xc8, 0xd9, 0xb5, 0x83, 0xca, 0xc2, 0x70, 0x6b, 0x2a, 0x90, 0x00, @@ -62,24 +69,19 @@ pub enum SimplicityNetwork { impl SimplicityNetwork { /// Creates a default Elements Regtest configuration. - /// - /// # Panics - /// This function will panic if the provided `LIQUID_DEFAULT_REGTEST_ASSET_STR` cannot be parsed. #[must_use] pub fn default_regtest() -> Self { - let policy_asset = elements::AssetId::from_str(LIQUID_DEFAULT_REGTEST_ASSET_STR).unwrap(); - Self::ElementsRegtest { policy_asset } + Self::ElementsRegtest { + policy_asset: *LIQUID_REGTEST_POLICY_ASSET, + } } /// Returns the policy `AssetId` associated with the current network. - /// - /// # Panics - /// This function will panic if the provided `LIQUID_DEFAULT_REGTEST_ASSET_STR` cannot be parsed. #[must_use] pub fn policy_asset(&self) -> elements::AssetId { match self { - Self::Liquid => elements::AssetId::from_str(LIQUID_POLICY_ASSET_STR).unwrap(), - Self::LiquidTestnet => elements::AssetId::from_str(LIQUID_TESTNET_POLICY_ASSET_STR).unwrap(), + Self::Liquid => *LIQUID_POLICY_ASSET, + Self::LiquidTestnet => *LIQUID_TESTNET_POLICY_ASSET, Self::ElementsRegtest { policy_asset } | Self::ElementsCustom { policy_asset, .. } => *policy_asset, } } diff --git a/crates/sdk/src/provider/rpc/elements.rs b/crates/sdk/src/provider/rpc/elements.rs index 82a2632b..fd631b84 100644 --- a/crates/sdk/src/provider/rpc/elements.rs +++ b/crates/sdk/src/provider/rpc/elements.rs @@ -46,7 +46,9 @@ impl ElementsRpc { let addr: Value = self.inner.call(METHOD, &[label.into(), "bech32".to_string().into()])?; - Ok(Address::from_str(addr.as_str().unwrap()).unwrap()) + let addr = addr.as_str().expect("`getnewaddress` returns a string"); + + Ok(Address::from_str(addr).unwrap_or_else(|error| panic!("`getnewaddress` returned `{addr}`: {error}"))) } /// Instructs the node to transfer funds directly to the target address. @@ -62,7 +64,7 @@ impl ElementsRpc { let btc = sat2btc(satoshi); let btc = bitcoin::amount::Amount::from_btc(btc) - .unwrap() + .expect("a satoshi amount is a valid BTC amount") .to_string_in(bitcoin::amount::Denomination::Bitcoin); let r = match asset { @@ -86,7 +88,9 @@ impl ElementsRpc { .call::(METHOD, &[address.to_string().into(), btc.into()])?, }; - Ok(Txid::from_str(r.as_str().unwrap()).unwrap()) + let txid = r.as_str().expect("`sendtoaddress` returns a string"); + + Ok(Txid::from_str(txid).unwrap_or_else(|error| panic!("`sendtoaddress` returned `{txid}`: {error}"))) } /// Instructs the node to rescan the block chain for missed wallet transactions. diff --git a/crates/sdk/src/provider/simplex.rs b/crates/sdk/src/provider/simplex.rs index 5629bed2..aacb262f 100644 --- a/crates/sdk/src/provider/simplex.rs +++ b/crates/sdk/src/provider/simplex.rs @@ -28,9 +28,12 @@ impl SimplexProvider { /// Panics if the `ElementsRpc` client fails to initialize. #[must_use] pub fn new(esplora_url: String, elements_url: String, auth: Auth, network: SimplicityNetwork) -> Self { + let elements = ElementsRpc::new(elements_url, auth) + .unwrap_or_else(|error| panic!("Cannot reach the Elements node: {error}")); + Self { esplora: EsploraProvider::new(esplora_url, network), - elements: ElementsRpc::new(elements_url, auth).unwrap(), + elements, } } } @@ -44,8 +47,9 @@ impl ProviderTrait for SimplexProvider { let tx_receipt = self.esplora.broadcast_transaction(tx)?; self.elements.generate_blocks(1)?; + self.esplora.wait(tx_receipt.as_ref())?; - Ok(tx_receipt) + Ok(TxReceipt::new(self, tx_receipt.txid())) } fn wait(&self, txid: &Txid) -> Result<(), ProviderError> { diff --git a/crates/sdk/src/signer/core.rs b/crates/sdk/src/signer/core.rs index 77b9e7b5..f0d51ca0 100644 --- a/crates/sdk/src/signer/core.rs +++ b/crates/sdk/src/signer/core.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -#[cfg(feature = "provider")] use std::collections::HashSet; +use std::ops::Deref; use std::str::FromStr; use std::sync::Arc; @@ -19,7 +19,10 @@ use bip39::rand::thread_rng; use elements_miniscript::{ ConfidentialDescriptor, Descriptor, DescriptorPublicKey, - bitcoin::{PrivateKey, PublicKey, bip32::DerivationPath}, + bitcoin::{ + PrivateKey, PublicKey, + bip32::{ChildNumber, DerivationPath}, + }, elements::{ EcdsaSighashType, bitcoin::bip32::{Fingerprint, Xpriv, Xpub}, @@ -30,22 +33,31 @@ use elements_miniscript::{ slip77::MasterBlindingKey, }; -use crate::constants::MIN_FEE; +use crate::constants::{DUST_THRESHOLD, 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::{ChangeOutput, FinalTransaction, PartialOutput, RequiredSignature, SigMessage}; +use crate::signer::witness_injector::WitnessInjector; +use crate::transaction::{ + ChangeOutput, FinalTransaction, PartialOutput, ProgramSignature, RequiredSignature, SigMessage, +}; #[cfg(feature = "provider")] -use crate::transaction::{PartialInput, TxReceipt, UTXO}; +use crate::transaction::{NativeSignature, PartialInput, TxReceipt, UTXO}; use super::error::SignerError; /// A placeholder dummy fee amount used during transaction estimation. pub const PLACEHOLDER_FEE: u64 = 1; +/// Discounted weight of a native input with its signature. +#[cfg(feature = "provider")] +const NATIVE_INPUT_WEIGHT: usize = 274; + +/// Weight units per virtual byte. +const WITNESS_SCALE_FACTOR: usize = 4; + /// Common signing interface spanning over standard explicit inputs and Simplicity programs. pub trait SignerTrait { /// Generates a Schnorr signature to satisfy a target Simplicity program input. @@ -81,12 +93,17 @@ pub trait SignerTrait { /// 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, #[cfg(feature = "provider")] provider: Option>, network: SimplicityNetwork, secp: Secp256k1, + /// The account key at `m/84h/{coin}h/0h`, derived once from the seed. + account_xprv: Xpriv, + /// The signing key at the default path `0/0` under the account. + default_key: PrivateKey, + address: Address, + confidential_address: Address, + blinding_key: PrivateKey, } impl SignerTrait for Signer { @@ -103,7 +120,7 @@ impl SignerTrait for Signer { let sighash = env.c_tx_env().sighash_all().to_byte_array(); let msg = Message::from_digest(message.digest(sighash)); - let private_key = self.get_private_key_at(derivation_path); + 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)) @@ -117,19 +134,7 @@ impl SignerTrait for Signer { ) -> Result<(PublicKey, ecdsa::Signature), SignerError> { let tx = pst.extract_tx()?; - let mut sighash_cache = SighashCache::new(&tx); - let genesis_hash = elements_miniscript::elements::BlockHash::GENESIS_PREVIOUS_BLOCK_HASH; - - let message = pst - .sighash_msg(input_index, &mut sighash_cache, None, genesis_hash)? - .to_secp_msg(); - - 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); - - Ok((public_key, signature)) + self.sign_input_with(pst, &mut SighashCache::new(&tx), input_index, derivation_path) } } @@ -166,19 +171,36 @@ impl Signer { let secp = Secp256k1::new(); let mnemonic: Mnemonic = mnemonic .parse() - .map_err(|e: bip39::Error| SignerError::Mnemonic(e.to_string())) - .unwrap(); + .unwrap_or_else(|error: bip39::Error| panic!("{}", SignerError::Mnemonic(error.to_string()))); let seed = mnemonic.to_seed(""); - let xprv = Xpriv::new_master(network, &seed).unwrap(); + let master_xprv = Xpriv::new_master(network, &seed).expect("a BIP-39 seed derives a master key"); + let master_blinding_key = MasterBlindingKey::from_seed(&seed[..]); + + let account_path = Self::account_path(network); + let account_xprv = master_xprv + .derive_priv(&secp, &account_path) + .expect("the account path derives from the master key"); + let default_key = Self::derive_key(&secp, &account_xprv, &Self::default_path(), network); + + let wpkh_descriptor = Self::wpkh_descriptor(&secp, &master_xprv, &account_path, &account_xprv); + let address = Self::wpkh_address(&wpkh_descriptor, network); + let confidential_address = Self::confidential_address(&secp, &wpkh_descriptor, master_blinding_key, network); + let blinding_key = PrivateKey::new( + master_blinding_key.blinding_private_key(&address.script_pubkey()), + network, + ); Self { - mnemonic, - xprv, #[cfg(feature = "provider")] provider: None, network, secp, + account_xprv, + default_key, + address, + confidential_address, + blinding_key, } } @@ -215,24 +237,38 @@ impl Signer { /// 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> { + Self::check_known_amounts(tx)?; + Self::check_unique_inputs(tx)?; + + let fee_rate = self.get_provider()?.fetch_fee_rate(1)?; + Self::check_fee_rate(fee_rate)?; + let mut signer_utxos = self.get_utxos_asset(self.network.policy_asset())?; - let mut set = HashSet::new(); + let mut spent: HashSet = tx.inputs().iter().map(|input| input.partial_input.outpoint).collect(); - for input in tx.inputs() { - set.insert(OutPoint { - txid: input.partial_input.witness_txid, - vout: input.partial_input.witness_output_index, - }); - } + // Skips the outpoints the transaction spends already and the ones the provider lists twice + signer_utxos.retain(|utxo| spent.insert(utxo.outpoint)); - signer_utxos.retain(|utxo| !set.contains(&utxo.outpoint)); + // An explicit change target can't balance a confidential input unless another output is confidential + if tx.change().is_some_and(|change| change.blinding_key.is_none()) && !tx.needs_blinding() { + // Retain only explicit UTXOs + signer_utxos.retain(UTXO::is_explicit); + } - // Descending sort of both confidential and explicit utxos - signer_utxos.sort_by_key(|utxo| std::cmp::Reverse(utxo.amount())); + // Resolve amounts before sorting so an unreadable UTXO returns an error. + let mut signer_utxos = signer_utxos + .into_iter() + .map(|utxo| utxo.amount().map(|amount| (amount, utxo))) + .collect::, _>>()?; + signer_utxos.sort_by_key(|(amount, _)| std::cmp::Reverse(*amount)); let mut fee_tx = tx.clone(); let mut curr_fee = MIN_FEE; - let fee_rate = self.get_provider()?.fetch_fee_rate(1)?; + + // Each input added after an estimate makes the transaction heavier, so + // the next estimate also accounts for the fee of those inputs. + let input_fee = Self::calculate_fee(NATIVE_INPUT_WEIGHT, fee_rate); + let mut inputs_since_estimate: u64 = 0; let try_estimate = |fee_tx: &FinalTransaction, policy_amount_delta: i64, curr_fee: &mut u64| match self .estimate_tx(fee_tx.clone(), fee_rate, policy_amount_delta.cast_unsigned()) @@ -251,30 +287,38 @@ impl Signer { } }; - for utxo in signer_utxos { - let policy_amount_delta = fee_tx.calculate_fee_delta(&self.network); + for (_, utxo) in signer_utxos { + let policy_amount_delta = fee_tx.calculate_fee_delta(&self.network)?; + let required_fee = curr_fee.saturating_add(inputs_since_estimate.saturating_mul(input_fee)); - if policy_amount_delta >= curr_fee.cast_signed() - && let Some(result) = try_estimate(&fee_tx, policy_amount_delta, &mut curr_fee)? - { - return Ok(result); + if i128::from(policy_amount_delta) >= i128::from(required_fee) { + inputs_since_estimate = 0; + + if let Some(result) = try_estimate(&fee_tx, policy_amount_delta, &mut curr_fee)? { + return Ok(result); + } } // IMPORTANT: must be added to the end of the transaction. // Otherwise, the logging of execution traces will be broken - fee_tx.add_input(PartialInput::new(utxo), RequiredSignature::NativeEcdsa); + fee_tx.add_input(PartialInput::new(utxo), Some(NativeSignature::Ecdsa)); + inputs_since_estimate += 1; } - // need to try one more time after the loop - let policy_amount_delta = fee_tx.calculate_fee_delta(&self.network); + // Need to try one more time after the loop. No UTXO is left to add, so any chance is taken + let policy_amount_delta = fee_tx.calculate_fee_delta(&self.network)?; - if policy_amount_delta >= curr_fee.cast_signed() - && let Some(result) = try_estimate(&fee_tx, policy_amount_delta, &mut curr_fee)? - { - return Ok(result); + if i128::from(policy_amount_delta) >= i128::from(curr_fee) { + inputs_since_estimate = 0; + + if let Some(result) = try_estimate(&fee_tx, policy_amount_delta, &mut curr_fee)? { + return Ok(result); + } } - Err(SignerError::NotEnoughFunds(curr_fee)) + Err(SignerError::NotEnoughFunds( + curr_fee.saturating_add(inputs_since_estimate.saturating_mul(input_fee)), + )) } /// Verifies and finalizes a transaction against a strict target confirmation window (in blocks). @@ -284,7 +328,11 @@ impl Signer { /// 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, fee_rate: f32) -> Result<(Transaction, u64), SignerError> { - let policy_amount_delta = tx.calculate_fee_delta(&self.network); + Self::check_known_amounts(tx)?; + Self::check_unique_inputs(tx)?; + Self::check_fee_rate(fee_rate)?; + + 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)); @@ -305,7 +353,11 @@ impl Signer { /// # Errors /// Returns a `SignerError` if the transaction cannot be signed. pub fn estimate_fee(&self, tx: &FinalTransaction, fee_rate: f32) -> Result { - let available_delta = tx.calculate_fee_delta(&self.network).max(0).cast_unsigned(); + Self::check_known_amounts(tx)?; + Self::check_unique_inputs(tx)?; + Self::check_fee_rate(fee_rate)?; + + let available_delta = tx.calculate_fee_delta(&self.network)?.max(0).cast_unsigned(); let estimate = self.estimate_tx(tx.clone(), fee_rate, available_delta); ProgramLogger::flush_logs(); @@ -348,8 +400,8 @@ impl Signer { Ok(pst) } - /// Blinds the transaction, signs the native inputs and injects signatures into program witnesses, - /// without executing or finalizing the programs. + /// Blinds the transaction, signs the `NativeEcdsa` inputs and injects signatures into program witnesses, + /// without executing or finalizing the programs. Inputs that require no signature stay unsigned. /// /// # Errors /// Returns a `SignerError` if blinding, signing or injecting a signature into a program witness fails. @@ -357,7 +409,9 @@ impl Signer { &self, tx: &FinalTransaction, ) -> Result<(PartiallySignedTransaction, HashMap), SignerError> { - let (mut pst, secrets) = tx.extract_pst(); + Self::check_known_amounts(tx)?; + + let (mut pst, secrets) = tx.extract_pst()?; let inputs = tx.inputs(); let mut signed_witnesses = HashMap::new(); @@ -365,40 +419,43 @@ impl Signer { pst.blind_last(&mut thread_rng(), &self.secp, &secrets)?; } + // Native inputs share one extracted transaction and sighash cache. + let mut sighash_cache = if inputs + .iter() + .any(|input| matches!(input.required_sig, RequiredSignature::NativeEcdsa)) + { + Some(SighashCache::new(Box::new(pst.extract_tx()?))) + } else { + None + }; + for (index, input_i) in inputs.iter().enumerate() { if let Some(program_input) = &input_i.program_input { - let signing_info: Option<(&String, &[String], &SigMessage)> = match &input_i.required_sig { - RequiredSignature::Witness(wtns_name) => Some((wtns_name, &[], &SigMessage::Sighash)), - RequiredSignature::WitnessWithPath(wtns_name, sig_path) => { - Some((wtns_name, sig_path, &SigMessage::Sighash)) - } - RequiredSignature::WitnessWithMessage(wtns_name, sig_path, message) => { - Some((wtns_name, sig_path, message)) - } - _ => None, - }; - - let signed_witness = match signing_info { + let signed_witness = match &input_i.required_sig { // Sign the program and inject the signature into the witness - Some((witness_name, sig_path, message)) => self.get_signed_program_witness( + RequiredSignature::Program(signature) => self.get_signed_program_witness( &pst, program_input.program.as_ref(), &program_input.witness, - witness_name, - sig_path, + signature, index, input_i.partial_input.derivation_path.as_ref(), - message, )?, // Just build the witness - None => program_input.witness.shallow_clone(), + _ => program_input.witness.shallow_clone(), }; signed_witnesses.insert(index, signed_witness); - } else { - // We need to sign the UTXO as is - // TODO: do we always sign? - let signed_witness = self.sign_input(&pst, index, input_i.partial_input.derivation_path.as_ref())?; + } else if matches!(input_i.required_sig, RequiredSignature::NativeEcdsa) + && let Some(sighash_cache) = sighash_cache.as_mut() + { + // An input that requires no signature is left for its owner to satisfy + let signed_witness = self.sign_input_with( + &pst, + sighash_cache, + 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 = @@ -425,59 +482,35 @@ impl Signer { } /// Returns the confidential elements address matching the local wallet logic. - /// - /// # Panics - /// Panics if the SLIP77 descriptor cannot be generated or parsed, or if address derivation fails. #[must_use] pub fn get_confidential_address(&self) -> Address { - let mut descriptor = - ConfidentialDescriptor::::from_str(&self.get_slip77_descriptor().unwrap()) - .map_err(|e| SignerError::Slip77Descriptor(e.to_string())) - .unwrap(); - - // Confidential descriptor doesn't support multipath - descriptor.descriptor = descriptor.descriptor.into_single_descriptors().unwrap()[0].clone(); - - descriptor - .at_derivation_index(0) - .unwrap() - .address(&self.secp, self.network.address_params()) - .unwrap() + self.confidential_address.clone() } /// Returns the standard unblinded address matching the local wallet logic. - /// - /// # Panics - /// Panics if the WPKH descriptor cannot be generated or parsed, or if address derivation fails. #[must_use] pub fn get_address(&self) -> Address { - let descriptor = Descriptor::::from_str(&self.get_wpkh_descriptor().unwrap()) - .map_err(|e| SignerError::WpkhDescriptor(e.to_string())) - .unwrap(); - - descriptor.into_single_descriptors().unwrap()[0] - .at_derivation_index(0) - .unwrap() - .address(self.network.address_params()) - .unwrap() + self.address.clone() } - /// Iterates against the network provider to select and unblind all known UTXOs. + /// Retrieves UTXOs with known assets and amounts, skipping outputs that cannot be unblinded. /// /// # Errors - /// Returns a `SignerError` if querying the network or unblinding operations fail. + /// Returns a `SignerError` if querying the provider fails. #[cfg(feature = "provider")] pub fn get_utxos(&self) -> Result, SignerError> { self.get_utxos_filter(&|_| true, &|_| true) } - /// Finds all known UTXOs belonging to the specific `AssetId`. + /// Finds usable UTXOs belonging to the specific `AssetId`, skipping unreadable outputs. /// /// # Errors - /// Returns a `SignerError` if network interaction or confidential output decryption fails. + /// Returns a `SignerError` if querying the provider 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) + self.get_utxos_filter(&|utxo| utxo.asset().is_ok_and(|id| id == asset), &|utxo| { + utxo.asset().is_ok_and(|id| id == asset) + }) } /// Finds all known UTXOs deriving from a targeted `Txid`. @@ -493,8 +526,12 @@ impl Signer { /// Maps UTXOs retrieved from the provider through arbitrary functional filters. /// Separate filtering criteria apply explicitly vs confidentially. /// + /// Outputs with null fields, unresolved partial commitments, or proofs that + /// cannot be rewound with the wallet key are skipped. Already supplied secrets + /// are preserved. A single unreadable payment must not block usable funds. + /// /// # Errors - /// Returns a `SignerError` if retrieving remote outputs or executing confidential node unblinding throws an error. + /// Returns a `SignerError` if retrieving remote outputs fails. #[cfg(feature = "provider")] pub fn get_utxos_filter( &self, @@ -502,25 +539,40 @@ impl Signer { confidential_filter: &dyn Fn(&UTXO) -> bool, ) -> Result, SignerError> { // Fetch explicit and confidential utxos - let mut all_utxos = self + let fetched_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( - all_utxos - .iter() - .filter(|utxo| utxo.txout.value.is_confidential()) - .cloned() - .collect(), - )?; - // Leave only explicit utxos - all_utxos.retain(|utxo| !utxo.txout.value.is_confidential()); + let mut all_utxos = Vec::new(); + let mut confidential_utxos = Vec::new(); + + for mut utxo in fetched_utxos { + if utxo.txout.asset.is_null() || utxo.txout.value.is_null() { + continue; + } + + if utxo.is_explicit() { + all_utxos.push(utxo); + continue; + } + + if utxo.secrets.is_none() { + let blinding_key = self.get_blinding_private_key(); + let Ok(secrets) = utxo.txout.unblind(&self.secp, blinding_key.inner) else { + // Payments to this script may use a foreign blinding key or + // an asset-only commitment. Neither can fund this wallet. + continue; + }; + + utxo.secrets = Some(secrets); + } + + confidential_utxos.push(utxo); + } all_utxos.retain(explicit_filter); confidential_utxos.retain(confidential_filter); - // Push unblinded utxos to explicit ones all_utxos.extend(confidential_utxos); Ok(all_utxos) @@ -547,72 +599,73 @@ impl Signer { self.get_blinding_private_key().public_key(&self.secp) } - /// Internally derives and exposes the wallet's signing active private key. - /// - /// # Panics - /// Panics if the master private key or derivation path cannot be derived. + /// Exposes the wallet's signing private key at the default path. #[must_use] pub fn get_private_key(&self) -> PrivateKey { - self.get_private_key_at(None) + self.default_key } /// 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 default_path; - let relative = if let Some(path) = relative { - path - } else { - 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(); + /// # Errors + /// Returns `SignerError::InvalidDerivationPath` if the path is not of the form `m/n`. + pub fn get_private_key_at(&self, relative: Option<&DerivationPath>) -> Result { + match relative { + Some(path) => { + Self::check_derivation_path(path)?; - PrivateKey::new(ext_derived.private_key, self.network) + Ok(Self::derive_key(&self.secp, &self.account_xprv, path, self.network)) + } + None => Ok(self.default_key), + } } - /// Generates the private key linked to confidential payload blinding. - /// - /// The generated `PrivateKey` is associated with the `Test` (non-Bitcoin/Liquid-mainnet) network kind. - /// Retrieves the blinding private key derived from the master SLIP77 key and the script public key of the address. - /// - /// # Panics - /// Panics if the master SLIP77 key cannot be derived. + /// The private key linked to confidential payload blinding, derived from the master SLIP77 key + /// and the script public key of the address. #[must_use] pub fn get_blinding_private_key(&self) -> PrivateKey { - let blinding_key = self - .master_slip77() - .unwrap() - .blinding_private_key(&self.get_address().script_pubkey()); + self.blinding_key + } - PrivateKey::new(blinding_key, self.network) + /// Checks that `path` is in correct form of `m/n`. + /// + /// # Errors + /// Returns `SignerError::InvalidDerivationPath` otherwise. + pub fn check_derivation_path(path: &DerivationPath) -> Result<(), SignerError> { + match path.as_ref() { + [ChildNumber::Normal { .. }, ChildNumber::Normal { .. }] => Ok(()), + _ => Err(SignerError::InvalidDerivationPath(path.clone())), + } } - #[cfg(feature = "provider")] - fn unblind(&self, utxos: Vec) -> Result, SignerError> { - let mut unblinded: Vec = Vec::new(); + fn sign_input_with( + &self, + pst: &PartiallySignedTransaction, + sighash_cache: &mut SighashCache>, + input_index: usize, + derivation_path: Option<&DerivationPath>, + ) -> Result<(PublicKey, ecdsa::Signature), SignerError> { + let message = pst + .sighash_msg(input_index, sighash_cache, None, self.network.genesis_block_hash())? + .to_secp_msg(); - for mut utxo in utxos { - let blinding_key = self.get_blinding_private_key(); - let secrets = utxo.txout.unblind(&self.secp, blinding_key.inner)?; + let private_key = self.get_private_key_at(derivation_path)?; + let public_key = private_key.public_key(&self.secp); - utxo.secrets = Some(secrets); + let script_pubkey = pst.inputs()[input_index] + .witness_utxo + .as_ref() + .map(|utxo| &utxo.script_pubkey); - unblinded.push(utxo); + if let Some(script_pubkey) = script_pubkey.filter(|script| script.is_v0_p2wpkh()) + && *script_pubkey != Script::new_v0_wpkh(&public_key.wpubkey_hash().expect("the key is compressed")) + { + return Err(SignerError::KeyMismatch(input_index)); } - Ok(unblinded) + let signature = self.secp.sign_ecdsa_low_r(&message, &private_key.inner); + + Ok((public_key, signature)) } fn estimate_tx( @@ -654,17 +707,27 @@ impl Signer { )); let final_tx = sign_and_extract_tx(self, &fee_tx)?; - let fee = fee_tx.calculate_fee(final_tx.discount_weight(), fee_rate); + let fee = Self::calculate_fee(final_tx.discount_weight(), fee_rate); - if available_delta > fee && available_delta - fee >= MIN_FEE { + let change_index = fee_tx.n_outputs() - 2; + let change_minimum = if fee_tx.outputs()[change_index].blinding_key.is_some() { + MIN_FEE + } else { + DUST_THRESHOLD + }; + + if available_delta + .checked_sub(fee) + .is_some_and(|change| change >= change_minimum) + { // We have enough funds to cover the change UTXO let outputs = fee_tx.outputs_mut(); outputs[outputs.len() - 2].amount = available_delta - fee; outputs[outputs.len() - 1].amount = fee; - if !fee_tx.is_balanced() { - return Err(SignerError::Unbalanced()); + if !fee_tx.is_balanced()? { + return Err(SignerError::Unbalanced); } let final_tx = sign_and_extract_tx(self, &fee_tx)?; @@ -672,25 +735,31 @@ impl Signer { return Ok(Estimate::Success(final_tx, fee)); } - // Not enough funds for the change, so estimate without it. - // Dropping the change is only safe while something else stays blinded. - // When it is the transaction's only blinded output and an input is confidential, - // removing it makes the transaction unblindable. - let change_index = fee_tx.n_outputs() - 2; let blinded_without_change = fee_tx .outputs() .iter() .enumerate() - .any(|(index, output)| index != change_index && output.blinding_key.is_some()); + .filter(|(index, output)| *index != change_index && output.blinding_key.is_some()) + .count(); + // Explicit change can always be dropped. + let change_needed = fee_tx.outputs()[change_index].blinding_key.is_some() + && if fee_tx.has_confidential_input() { + // Change is the only confidential output -> must stay + blinded_without_change == 0 + } else { + // There is another confidential output -> must stay to preserve privacy + blinded_without_change == 1 + }; - if fee_tx.has_confidential_input() && !blinded_without_change { - return Ok(Estimate::Failure(fee + MIN_FEE)); + if change_needed { + return Ok(Estimate::Failure(fee.saturating_add(change_minimum))); } + // Not enough funds for the change, so estimate without it. fee_tx.remove_output(change_index); let final_tx = sign_and_extract_tx(self, &fee_tx)?; - let fee = fee_tx.calculate_fee(final_tx.discount_weight(), fee_rate); + let fee = Self::calculate_fee(final_tx.discount_weight(), fee_rate); if available_delta < fee { return Ok(Estimate::Failure(fee)); @@ -701,55 +770,61 @@ impl Signer { // Change the fee output amount outputs[outputs.len() - 1].amount = available_delta; - if !fee_tx.is_balanced() { - return Err(SignerError::Unbalanced()); + if !fee_tx.is_balanced()? { + return Err(SignerError::Unbalanced); } - // Finalize the tx with fee and without the change + // Finalize the tx with fee and without the change, which pays the whole delta let final_tx = sign_and_extract_tx(self, &fee_tx)?; - Ok(Estimate::Success(final_tx, fee)) + Ok(Estimate::Success(final_tx, available_delta)) } /// Signs and inserts a signature into appropriate witness value. /// /// # Errors /// Returns a `SignerError` if signing the program fails, if the witness types cannot be - /// retrieved from the program, if `witness_name` is not present among the program's - /// witness fields, or if injecting the signature into the witness value at `sig_path` fails. - #[allow(clippy::too_many_arguments)] + /// retrieved from the program, if the signature's witness field is not present among the program's + /// witness fields, or if injecting the signature into the witness value at its path fails. fn get_signed_program_witness( &self, pst: &PartiallySignedTransaction, program: &dyn ProgramTrait, witness: &WitnessValues, - witness_name: &str, - sig_path: &[String], + program_signature: &ProgramSignature, index: usize, derivation_path: Option<&DerivationPath>, - message: &SigMessage, ) -> Result { - let signature = self.sign_program(pst, program, index, &self.network, derivation_path, message)?; + let witness_name = program_signature.name(); + let sig_path = program_signature.path(); + let signature = self.sign_program( + pst, + program, + index, + &self.network, + derivation_path, + program_signature.message(), + )?; - // Inject the signature into the wtns name directly if the path is not provided + // Inject the signature into the witness field directly if the path is not provided let sig_val = if sig_path.is_empty() { Value::byte_array(signature.serialize()) } else { let witness_types = program.get_witness_types()?; let witness_type = witness_types .get(&TemplateProgramWitness::witness_from_str(witness_name)) - .ok_or(SignerError::WtnsFieldNotFound(witness_name.to_string()))?; + .ok_or(SignerError::WitnessFieldNotFound(witness_name.to_string()))?; #[allow(clippy::missing_panics_doc)] - let local_wtns = Arc::new( + let local_witness = Arc::new( witness .get(&TemplateProgramWitness::witness_from_str(witness_name)) .expect("checked above") .clone(), ); - WtnsInjector::inject_value( - &local_wtns, + WitnessInjector::inject_value( + &local_witness, witness_type, sig_path, Value::byte_array(signature.serialize()), @@ -767,68 +842,251 @@ impl Signer { Ok(WitnessValues::from_map(hm)) } - #[allow(clippy::unnecessary_wraps)] - fn master_slip77(&self) -> Result { - let seed = self.mnemonic.to_seed(""); - - Ok(MasterBlindingKey::from_seed(&seed[..])) - } + /// The fee in satoshis for `weight` at `fee_rate` sats/kvb, both rounded up. + #[allow( + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + clippy::cast_sign_loss + )] + fn calculate_fee(weight: usize, fee_rate: f32) -> u64 { + let vsize = weight.div_ceil(WITNESS_SCALE_FACTOR); - fn derive_xpriv(&self, path: &DerivationPath) -> Result { - Ok(self.xprv.derive_priv(&self.secp, &path)?) + (vsize as f32 * fee_rate / 1000.0).ceil() as u64 } - fn master_xpriv(&self) -> Result { - self.derive_xpriv(&DerivationPath::master()) + /// A fee rate is a finite, non-negative number of sats/kvb. + fn check_fee_rate(fee_rate: f32) -> Result<(), SignerError> { + if fee_rate.is_finite() && fee_rate >= 0.0 { + Ok(()) + } else { + Err(SignerError::InvalidFeeRate(fee_rate)) + } } - fn derive_xpub(&self, path: &DerivationPath) -> Result { - let derived = self.derive_xpriv(path)?; + /// A transaction spending an outpoint twice can never confirm. + fn check_unique_inputs(tx: &FinalTransaction) -> Result<(), SignerError> { + let mut seen = HashSet::new(); - Ok(Xpub::from_priv(&self.secp, &derived)) + match tx + .inputs() + .iter() + .find(|input| !seen.insert(input.partial_input.outpoint)) + { + Some(input) => Err(SignerError::DuplicateInput(input.partial_input.outpoint)), + None => Ok(()), + } } - fn master_xpub(&self) -> Result { - self.derive_xpub(&DerivationPath::master()) + /// Balancing and signing need the amount of every input. + fn check_known_amounts(tx: &FinalTransaction) -> Result<(), SignerError> { + match tx + .inputs() + .iter() + .find(|input| input.partial_input.asset_and_amount().is_none()) + { + Some(input) => Err(SignerError::UnknownInputAmount(input.partial_input.outpoint)), + None => Ok(()), + } } - fn fingerprint(&self) -> Result { - Ok(self.master_xpub()?.fingerprint()) + /// The account path, `m/84h/1776h/0h` on mainnet and `m/84h/1h/0h` elsewhere. + fn account_path(network: SimplicityNetwork) -> DerivationPath { + let coin_type = if network.is_mainnet() { 1776 } else { 1 }; + + DerivationPath::from_str(&format!("m/84h/{coin_type}h/0h")).expect("the account path is valid") } - fn get_slip77_descriptor(&self) -> Result { - let wpkh_descriptor = self.get_wpkh_descriptor()?; - let blinding_key = self.master_slip77()?; + /// The default signing path under the account. + fn default_path() -> DerivationPath { + DerivationPath::from_str("0/0").expect("the default path is valid") + } - Ok(format!("ct(slip77({blinding_key}),{wpkh_descriptor})")) + /// # Panics + /// Panics if `path` cannot be derived from `account_xprv`. + fn derive_key( + secp: &Secp256k1, + account_xprv: &Xpriv, + path: &DerivationPath, + network: SimplicityNetwork, + ) -> PrivateKey { + let derived = account_xprv + .derive_priv(secp, path) + .unwrap_or_else(|error| panic!("Cannot derive a key at `{path}`: {error}")); + + PrivateKey::new(derived.private_key, network) } - fn get_wpkh_descriptor(&self) -> Result { - let fingerprint = self.fingerprint()?; - let path = self.get_derivation_path()?; - let mut xpub = self.derive_xpub(&path)?; + fn wpkh_descriptor( + secp: &Secp256k1, + master_xprv: &Xpriv, + account_path: &DerivationPath, + account_xprv: &Xpriv, + ) -> String { + let fingerprint = Xpub::from_priv(secp, master_xprv).fingerprint(); + let mut xpub = Xpub::from_priv(secp, account_xprv); // TODO: Blockstream app does this. Is this a bug? xpub.parent_fingerprint = Fingerprint::default(); - Ok(format!("elwpkh([{fingerprint}/{path}]{xpub}/<0;1>/*)")) + format!("elwpkh([{fingerprint}/{account_path}]{xpub}/<0;1>/*)") } - fn get_derivation_path(&self) -> Result { - let coin_type = if self.network.is_mainnet() { 1776 } else { 1 }; - let path = format!("84h/{coin_type}h/0h"); + /// # Panics + /// Panics if the WPKH descriptor cannot be parsed or the address cannot be derived. + fn wpkh_address(wpkh_descriptor: &str, network: SimplicityNetwork) -> Address { + let descriptor = + Descriptor::::from_str(wpkh_descriptor).expect("the wallet's descriptor parses"); - DerivationPath::from_str(&format!("m/{path}")).map_err(|e| SignerError::DerivationPath(e.to_string())) + descriptor + .into_single_descriptors() + .expect("the wallet's descriptor is single-path")[0] + .at_derivation_index(0) + .expect("index 0 is a valid derivation index") + .address(network.address_params()) + .expect("a WPKH descriptor has an address") + } + + /// # Panics + /// Panics if the SLIP77 descriptor cannot be parsed or the address cannot be derived. + fn confidential_address( + secp: &Secp256k1, + wpkh_descriptor: &str, + master_blinding_key: MasterBlindingKey, + network: SimplicityNetwork, + ) -> Address { + let mut descriptor = ConfidentialDescriptor::::from_str(&format!( + "ct(slip77({master_blinding_key}),{wpkh_descriptor})" + )) + .expect("the wallet's confidential descriptor parses"); + + // Confidential descriptor doesn't support multipath + descriptor.descriptor = descriptor + .descriptor + .into_single_descriptors() + .expect("the wallet's descriptor is single-path")[0] + .clone(); + + descriptor + .at_derivation_index(0) + .expect("index 0 is a valid derivation index") + .address(secp, network.address_params()) + .expect("a SLIP77 descriptor has an address") } } #[cfg(test)] mod tests { - use crate::provider::EsploraProvider; + use simplicityhl::elements::confidential::{Asset, AssetBlindingFactor, Value}; + use simplicityhl::elements::{OutPoint, TxOut, Txid}; + + use simplicityhl::elements::TxOutSecrets; + use simplicityhl::elements::confidential::ValueBlindingFactor; + + use crate::provider::{EsploraProvider, ProviderError}; + use crate::transaction::{FinalTransactionError, PartialInput, UTXO}; use crate::utils::random_mnemonic; use super::*; + const MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + struct StubProvider { + network: SimplicityNetwork, + utxos: Vec, + } + + impl ProviderTrait for StubProvider { + fn get_network(&self) -> &SimplicityNetwork { + &self.network + } + + fn fetch_address_utxos(&self, _: &Address) -> Result, ProviderError> { + Ok(self.utxos.clone()) + } + + fn fetch_fee_estimates(&self) -> Result, ProviderError> { + Ok(HashMap::from([("1".to_string(), 0.1)])) + } + + fn broadcast_transaction(&self, _: &Transaction) -> Result, ProviderError> { + unimplemented!() + } + + fn wait(&self, _: &Txid) -> Result<(), ProviderError> { + unimplemented!() + } + + fn fetch_tip_height(&self) -> Result { + unimplemented!() + } + + fn fetch_tip_block_hash(&self) -> Result { + unimplemented!() + } + + fn fetch_tip_timestamp(&self) -> Result { + unimplemented!() + } + + fn fetch_block_hash_at_height(&self, _: u32) -> Result { + unimplemented!() + } + + fn fetch_block_txids(&self, _: &str) -> Result, ProviderError> { + unimplemented!() + } + + fn fetch_transaction(&self, _: &Txid) -> Result { + unimplemented!() + } + + fn fetch_scripthash_utxos(&self, _: &Script) -> Result, ProviderError> { + unimplemented!() + } + } + + fn wallet_with(utxos: impl FnOnce(&Signer) -> Vec) -> Signer { + let network = SimplicityNetwork::default_regtest(); + let utxos = utxos(&Signer::from_mnemonic(MNEMONIC, network)); + + Signer::new(MNEMONIC, Box::new(StubProvider { network, utxos })) + } + + fn explicit_coin(signer: &Signer, vout: u32, amount: u64) -> UTXO { + UTXO::explicit( + OutPoint::new(Txid::from_byte_array([0x02; 32]), vout), + signer.get_address().script_pubkey(), + amount, + signer.network.policy_asset(), + ) + } + + fn confidential_coin(signer: &Signer, vout: u32, amount: u64) -> UTXO { + let asset = signer.network.policy_asset(); + let spent = [TxOutSecrets::new( + asset, + AssetBlindingFactor::zero(), + amount, + ValueBlindingFactor::zero(), + )]; + let (txout, ..) = TxOut::new_not_last_confidential( + &mut thread_rng(), + &signer.secp, + amount, + &signer.get_confidential_address(), + asset, + &spent, + ) + .unwrap(); + + UTXO { + outpoint: OutPoint::new(Txid::from_byte_array([0x02; 32]), vout), + txout, + secrets: None, + } + } + fn create_signer() -> Signer { let url = "https://blockstream.info/liquidtestnet/api".to_string(); let network = SimplicityNetwork::Liquid; @@ -857,7 +1115,7 @@ mod tests { let signer = create_signer(); let mut ft = FinalTransaction::new(); - ft.add_input(confidential_input(&signer, 100_000), RequiredSignature::NativeEcdsa); + ft.add_input(confidential_input(&signer, 100_000), Some(NativeSignature::Ecdsa)); ft.add_output(PartialOutput::new( signer.get_address().script_pubkey(), 50_000, @@ -876,7 +1134,7 @@ mod tests { let signer = create_signer(); let mut ft = FinalTransaction::new(); - ft.add_input(confidential_input(&signer, 100_000), RequiredSignature::NativeEcdsa); + ft.add_input(confidential_input(&signer, 100_000), Some(NativeSignature::Ecdsa)); ft.add_output( PartialOutput::new( signer.get_address().script_pubkey(), @@ -898,7 +1156,7 @@ mod tests { let signer = create_signer(); let mut ft = FinalTransaction::new(); - ft.add_input(confidential_input(&signer, 100_000), RequiredSignature::NativeEcdsa); + ft.add_input(confidential_input(&signer, 100_000), Some(NativeSignature::Ecdsa)); ft.add_output(PartialOutput::new( signer.get_address().script_pubkey(), 50_000, @@ -935,10 +1193,32 @@ mod tests { println!("{}", signer.get_confidential_address()); } + #[test] + fn input_without_known_amount_is_an_error() { + use simplicityhl::elements::TxOut; + + let signer = create_signer(); + let outpoint = OutPoint::new(Txid::from_byte_array([0x03; 32]), 1); + + let mut ft = FinalTransaction::new(); + ft.add_input( + PartialInput::new(UTXO { + outpoint, + txout: TxOut::default(), + secrets: None, + }), + Some(NativeSignature::Ecdsa), + ); + + assert!(matches!( + signer.sign_witnesses(&ft), + Err(SignerError::UnknownInputAmount(unknown)) if unknown == outpoint + )); + } + #[test] fn sign_witnesses_leaves_failing_programs_for_the_caller_to_execute() { use simplicityhl::Arguments; - use simplicityhl::elements::TxOut; use simplicityhl::simplicity::bit_machine::ExecutionError; @@ -948,27 +1228,26 @@ mod tests { let signer = create_signer(); let program = Program::new("fn main() { assert!(false); }", Arguments::default()); - let mut program_txout = TxOut::new_fee(1000, signer.network.policy_asset()); - program_txout.script_pubkey = program.get_tr_address(&signer.network).script_pubkey(); - let mut ft = FinalTransaction::new(); // A native input first, so the program sits at index 1 ft.add_input( - PartialInput::new(UTXO { - outpoint: OutPoint::new(Txid::from_byte_array([0x01; 32]), 0), - txout: TxOut::new_fee(1000, signer.network.policy_asset()), - secrets: None, - }), - RequiredSignature::NativeEcdsa, + PartialInput::new(UTXO::explicit( + OutPoint::new(Txid::from_byte_array([0x01; 32]), 0), + Script::new(), + 1000, + signer.network.policy_asset(), + )), + Some(NativeSignature::Ecdsa), ); ft.add_program_input( - PartialInput::new(UTXO { - outpoint: OutPoint::new(Txid::from_byte_array([0x02; 32]), 0), - txout: program_txout, - secrets: None, - }), + PartialInput::new(UTXO::explicit( + OutPoint::new(Txid::from_byte_array([0x02; 32]), 0), + program.get_tr_address(&signer.network).script_pubkey(), + 1000, + signer.network.policy_asset(), + )), ProgramInput::new(Box::new(program.clone()), WitnessValues::default()), - RequiredSignature::None, + None, ); let (pst, signed_witnesses) = signer.sign_witnesses(&ft).unwrap(); @@ -989,4 +1268,194 @@ mod tests { Err(SignerError::CovenantExecution { index: 1, .. }) )); } + + #[test] + fn unresolved_inputs_are_rejected_before_signing() { + let signer = create_signer(); + let network = signer.network; + let (amount_in, amount_out, fee_rate) = (100_000, 10_000, 100.); + + let script = signer.get_address().script_pubkey(); + let funding = UTXO { + outpoint: OutPoint::new(Txid::from_byte_array(Default::default()), 0), + txout: TxOut { + script_pubkey: script.clone(), + ..TxOut::new_fee(amount_in, network.policy_asset()) + }, + secrets: None, + }; + + let mut ft = FinalTransaction::new(); + ft.add_input(PartialInput::new(funding.clone()), Some(NativeSignature::Ecdsa)); + ft.add_output(PartialOutput::new(script.clone(), amount_out, network.policy_asset())); + + // The signer can finalize valid caller-selected inputs successfully. + let (funded, _) = signer.finalize_strict(&ft, fee_rate).unwrap(); + assert_eq!(funded.input.len(), 1); + funded.verify_tx_amt_proofs(&signer.secp, &[funding.txout]).unwrap(); + + // Adding a malformed input. + let mixed = UTXO { + outpoint: OutPoint::new(Txid::from_byte_array(Default::default()), 0), + txout: TxOut { + asset: Asset::new_confidential( + &signer.secp, + network.policy_asset(), + AssetBlindingFactor::new(&mut thread_rng()), + ), + value: Value::Explicit(600), + script_pubkey: script, + ..Default::default() + }, + secrets: None, + }; + ft.add_input(PartialInput::new(mixed), Some(NativeSignature::Ecdsa)); + + // Direct accounting reports the unresolved input's position. + assert_eq!( + ft.calculate_fee_delta(&network), + Err(FinalTransactionError::MissingInputSecrets { input_index: 1 }) + ); + assert_eq!( + ft.is_balanced(), + Err(FinalTransactionError::MissingInputSecrets { input_index: 1 }) + ); + assert!(matches!( + ft.extract_pst(), + Err(FinalTransactionError::MissingInputSecrets { input_index: 1 }) + )); + + // Signer entry points preserve the error identifying the unresolved outpoint. + for result in [ + signer.estimate_fee(&ft, 100.0).map(|_| ()), + signer.finalize_strict(&ft, 100.0).map(|_| ()), + signer.sign_witnesses(&ft).map(|_| ()), + signer.sign_tx(&ft).map(|_| ()), + ] { + assert!(matches!(result, Err(SignerError::UnknownInputAmount(_)))); + } + } + + #[test] + fn coin_selection_skips_a_coin_listed_twice() { + let signer = wallet_with(|owner| vec![explicit_coin(owner, 0, 100_000), explicit_coin(owner, 0, 100_000)]); + + let mut ft = FinalTransaction::new(); + ft.add_output(PartialOutput::new( + signer.get_address().script_pubkey(), + 150_000, + signer.network.policy_asset(), + )); + + assert!(matches!(signer.finalize(&ft), Err(SignerError::NotEnoughFunds(_)))); + } + + #[test] + fn explicit_change_skips_confidential_coins() { + let signer = wallet_with(|owner| vec![explicit_coin(owner, 0, 50_000), confidential_coin(owner, 1, 100_000)]); + let script = signer.get_address().script_pubkey(); + + let mut ft = FinalTransaction::new(); + ft.add_output(PartialOutput::new( + script.clone(), + 10_000, + signer.network.policy_asset(), + )); + ft.add_change(ChangeOutput::new(script)); + + let (tx, _) = signer.finalize(&ft).unwrap(); + + assert_eq!(tx.input.len(), 1); + assert_eq!(tx.input[0].previous_output.vout, 0); + } + + #[test] + fn invalid_fee_rates_and_duplicate_inputs_are_rejected() { + let signer = create_signer(); + let coin = explicit_coin(&signer, 0, 100_000); + + let mut ft = FinalTransaction::new(); + ft.add_input(PartialInput::new(coin.clone()), Some(NativeSignature::Ecdsa)); + ft.add_output(PartialOutput::new( + signer.get_address().script_pubkey(), + 10_000, + signer.network.policy_asset(), + )); + + for fee_rate in [f32::NAN, -1.0, f32::INFINITY] { + assert!(matches!( + signer.finalize_strict(&ft, fee_rate), + Err(SignerError::InvalidFeeRate(_)) + )); + assert!(matches!( + signer.estimate_fee(&ft, fee_rate), + Err(SignerError::InvalidFeeRate(_)) + )); + } + + ft.add_input(PartialInput::new(coin), Some(NativeSignature::Ecdsa)); + + assert!(matches!( + signer.finalize_strict(&ft, 100.0), + Err(SignerError::DuplicateInput(_)) + )); + } + + #[test] + fn leftover_too_small_for_change_is_the_fee_unless_it_reveals_a_blinded_amount() { + let signer = create_signer(); + let policy = signer.network.policy_asset(); + let script = signer.get_address().script_pubkey(); + let coin = explicit_coin(&signer, 0, 100_000); + + let mut ft = FinalTransaction::new(); + ft.add_input(PartialInput::new(coin.clone()), Some(NativeSignature::Ecdsa)); + ft.add_output(PartialOutput::new(script.clone(), 99_970, policy)); + + let (tx, fee) = signer.finalize_strict(&ft, 100.0).unwrap(); + + assert_eq!(tx.output.len(), 2, "the change is dropped"); + assert_eq!((fee, tx.fee_in(policy)), (30, 30)); + + // Without change, the inputs minus the fee would reveal the only blinded output + let mut ft = FinalTransaction::new(); + ft.add_input(PartialInput::new(coin), Some(NativeSignature::Ecdsa)); + ft.add_output(PartialOutput::new(script, 99_970, policy).with_blinding_key(signer.get_blinding_public_key())); + + assert!(matches!( + signer.finalize_strict(&ft, 100.0), + Err(SignerError::NotEnoughFeeAmount(..)) + )); + } + + #[test] + fn only_wallet_paths_owning_the_input_sign_it() { + let signer = create_signer(); + let path = |path: &str| DerivationPath::from_str(path).unwrap(); + + for valid in ["0/0", "0/7", "1/3", "2/7"] { + assert!(Signer::check_derivation_path(&path(valid)).is_ok(), "{valid}"); + } + for invalid in ["", "m", "7", "0/7h", "84h/1h/0h/0/7"] { + assert!(Signer::check_derivation_path(&path(invalid)).is_err(), "{invalid}"); + } + + let sign_with = |relative: &str| { + let mut ft = FinalTransaction::new(); + ft.add_input( + PartialInput::new(explicit_coin(&signer, 0, 100_000)).with_derivation_path(path(relative)), + Some(NativeSignature::Ecdsa), + ); + ft.add_output(PartialOutput::new( + Script::new(), + 100_000, + signer.network.policy_asset(), + )); + + signer.sign_tx(&ft) + }; + + assert!(sign_with("0/0").is_ok()); + assert!(matches!(sign_with("0/7"), Err(SignerError::KeyMismatch(0)))); + } } diff --git a/crates/sdk/src/signer/error.rs b/crates/sdk/src/signer/error.rs index f59926c5..51bfbf10 100644 --- a/crates/sdk/src/signer/error.rs +++ b/crates/sdk/src/signer/error.rs @@ -1,5 +1,8 @@ +use elements_miniscript::bitcoin::bip32::DerivationPath; + use crate::program::ProgramError; use crate::provider::ProviderError; +use crate::transaction::{FinalTransactionError, UtxoError}; /// Core error types for the Signer component. #[derive(Debug, thiserror::Error)] @@ -27,6 +30,14 @@ pub enum SignerError { #[error(transparent)] Provider(#[from] ProviderError), + /// A UTXO is missing its asset or amount and has no unblinding secrets. + #[error(transparent)] + Utxo(#[from] UtxoError), + + /// Errors originating from transaction construction or validation. + #[error(transparent)] + FinalTransaction(#[from] FinalTransactionError), + /// 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")] @@ -34,7 +45,7 @@ pub enum SignerError { /// Errors encountered when attempting to inject or wrap witness fields. #[error(transparent)] - WtnsInjectError(#[from] WtnsWrappingError), + WitnessInjectError(#[from] WitnessWrappingError), /// Error indicating an incorrectly formatted mnemonic phrase. #[error("Failed to parse a mnemonic: {0}")] @@ -62,7 +73,7 @@ pub enum SignerError { /// Error indicating the transaction is not balanced. #[error("Transaction is not balanced or malformed")] - Unbalanced(), + Unbalanced, /// Error indicating the transaction inputs cover an amount that is lower than the dust limit. #[error("Fee amount is too low: {0}")] @@ -72,6 +83,14 @@ pub enum SignerError { #[error("Not enough fee amount {0} to cover transaction costs: {1}")] NotEnoughFeeAmount(i64, u64), + /// Error indicating a fee rate that is not a finite, non-negative number of sats/kvb. + #[error("Invalid fee rate: {0} sats/kvb")] + InvalidFeeRate(f32), + + /// Error indicating the transaction spends the same outpoint more than once. + #[error("The transaction spends {0} more than once")] + DuplicateInput(simplicityhl::elements::OutPoint), + /// Error indicating that the available UTXO funds are not enough to cover total costs. #[error("Not enough funds on account to cover transaction costs: {0}")] NotEnoughFunds(u64), @@ -83,6 +102,14 @@ pub enum SignerError { #[error("A confidential input needs at least one blinded output, but the change target is explicit")] ConfidentialInputWithoutBlindedOutput, + /// Error indicating a derivation path isn't `m/n`. + #[error("The derivation path `{0}` is not of the form m/n")] + InvalidDerivationPath(DerivationPath), + + /// Error indicating the key an input is signed with doesn't own the output it spends. + #[error("Input {0} is locked to another key than its derivation path gives")] + KeyMismatch(usize), + /// Error indicating an invalid upstream `secp256k1` secret key. #[error("Invalid secret key")] InvalidSecretKey(#[from] simplicityhl::elements::secp256k1_zkp::UpstreamError), @@ -111,14 +138,18 @@ pub enum SignerError { #[error("Failed to construct a wpkh address: {0}")] WpkhAddressConstruction(#[from] elements_miniscript::Error), + /// Error indicating an input whose amount the signer cannot know. + #[error("The input spending {0} is confidential and has no unblinding secrets, so its amount is unknown")] + UnknownInputAmount(simplicityhl::elements::OutPoint), + /// Error indicating an expected witness field could not be found. #[error("Missing such witness field: {0}")] - WtnsFieldNotFound(String), + WitnessFieldNotFound(String), } /// Errors originating from manipulating witness paths and injecting values. #[derive(Debug, thiserror::Error)] -pub enum WtnsWrappingError { +pub enum WitnessWrappingError { /// Error indicating a failure while parsing the provided witness path string. #[error("Failed to parse path")] ParsingError, diff --git a/crates/sdk/src/signer/mod.rs b/crates/sdk/src/signer/mod.rs index dc8007e9..f2c9c1a7 100644 --- a/crates/sdk/src/signer/mod.rs +++ b/crates/sdk/src/signer/mod.rs @@ -3,7 +3,7 @@ pub mod core; /// Signer-specific error enumerations capturing execution constraints and mapping internal failure types. pub mod error; /// Utilities for injecting witness data bindings into Simplicity environments. -mod wtns_injector; +mod witness_injector; pub use core::{Signer, SignerTrait}; pub use error::SignerError; diff --git a/crates/sdk/src/signer/wtns_injector.rs b/crates/sdk/src/signer/witness_injector.rs similarity index 78% rename from crates/sdk/src/signer/wtns_injector.rs rename to crates/sdk/src/signer/witness_injector.rs index 1084167b..4a6b18b4 100644 --- a/crates/sdk/src/signer/wtns_injector.rs +++ b/crates/sdk/src/signer/witness_injector.rs @@ -7,7 +7,7 @@ use simplicityhl::{ value::{ValueConstructible, ValueInner}, }; -use crate::signer::error::WtnsWrappingError; +use crate::signer::error::WitnessWrappingError; /// Represents an index-based route for array or tuple witness types. #[derive(Clone, Copy, Debug)] @@ -22,14 +22,14 @@ pub(super) enum EitherRoute { /// Represents a single step in a witness path, either following a branch or an index. #[derive(Clone, Copy, Debug)] -pub(super) enum WtnsPathRoute { +pub(super) enum WitnessPathRoute { Either(EitherRoute), Enumerable(EnumerableRoute), } /// Exposes utilities to safely inject values into specific locations within a Simplicity witness structure. #[derive(Clone)] -pub(super) struct WtnsInjector {} +pub(super) struct WitnessInjector {} enum StackItem { Either(EitherRoute, Arc), @@ -37,13 +37,13 @@ enum StackItem { Tuple(EnumerableRoute, Arc<[Value]>), } -impl WtnsInjector { +impl WitnessInjector { /// Constructs a new value by injecting a given value into the witness at the position described by `path`. /// /// Consistency between `witness` and `witness_types` should be guaranteed by the caller. /// /// # Errors - /// Returns a `WtnsWrappingError` if the path contains invalid segments, attempts to access an out-of-bounds index, navigates into an incorrect type layout, or expects a different branch representation. + /// Returns a `WitnessWrappingError` if the path contains invalid segments, attempts to access an out-of-bounds index, navigates into an incorrect type layout, or expects a different branch representation. /// /// # Panics /// Panics if internal type validations or downcasts fail after safety checks have passed. @@ -52,7 +52,7 @@ impl WtnsInjector { witness_types: &ResolvedType, path: I, value: Value, - ) -> Result + ) -> Result where I: IntoIterator, I::Item: AsRef, @@ -67,11 +67,11 @@ impl WtnsInjector { if !matches!( (route, current_ty.as_inner()), ( - WtnsPathRoute::Enumerable(_), + WitnessPathRoute::Enumerable(_), TypeInner::Array(_, _) | TypeInner::Tuple(_) - ) | (WtnsPathRoute::Either(_), TypeInner::Either(_, _)) + ) | (WitnessPathRoute::Either(_), TypeInner::Either(_, _)) ) { - return Err(WtnsWrappingError::UnsupportedPathType(current_ty.to_string())); + return Err(WitnessWrappingError::UnsupportedPathType(current_ty.to_string())); } match current_ty.as_inner() { @@ -90,14 +90,14 @@ impl WtnsInjector { current_ty = right_ty; current_val = Arc::clone(either_val.as_ref().unwrap_right()); } - _ => return Err(WtnsWrappingError::EitherBranchMismatch), + _ => return Err(WitnessWrappingError::EitherBranchMismatch), } } TypeInner::Array(ty, len) => { let idx: EnumerableRoute = (*route).try_into().expect("Checked in matches! above"); if idx.0 >= *len { - return Err(WtnsWrappingError::IdxOutOfBounds(*len, idx.0)); + return Err(WitnessWrappingError::IdxOutOfBounds(*len, idx.0)); } let arr_val = Self::downcast_array(¤t_val); @@ -111,7 +111,7 @@ impl WtnsInjector { let idx: EnumerableRoute = (*route).try_into().expect("Checked in matches! above"); if idx.0 >= tuple.len() { - return Err(WtnsWrappingError::IdxOutOfBounds(tuple.len(), idx.0)); + return Err(WitnessWrappingError::IdxOutOfBounds(tuple.len(), idx.0)); } let tuple_val = Self::downcast_tuple(¤t_val); @@ -126,7 +126,7 @@ impl WtnsInjector { } if value.ty() != current_ty { - return Err(WtnsWrappingError::RootTypeMismatch( + return Err(WitnessWrappingError::RootTypeMismatch( current_ty.to_string(), value.ty().to_string(), )); @@ -156,19 +156,19 @@ impl WtnsInjector { Ok(value) } - fn parse_path(path: I) -> Result, WtnsWrappingError> + fn parse_path(path: I) -> Result, WitnessWrappingError> where I: IntoIterator, I::Item: AsRef, { path.into_iter() .map(|route| match route.as_ref() { - "Left" => Ok(WtnsPathRoute::Either(EitherRoute::Left)), - "Right" => Ok(WtnsPathRoute::Either(EitherRoute::Right)), + "Left" => Ok(WitnessPathRoute::Either(EitherRoute::Left)), + "Right" => Ok(WitnessPathRoute::Either(EitherRoute::Right)), s => s .parse::() - .map(|n| WtnsPathRoute::Enumerable(EnumerableRoute(n))) - .map_err(|_| WtnsWrappingError::ParsingError), + .map(|n| WitnessPathRoute::Enumerable(EnumerableRoute(n))) + .map_err(|_| WitnessWrappingError::ParsingError), }) .collect::, _>>() } @@ -195,8 +195,8 @@ impl WtnsInjector { } } -impl TryInto for WtnsPathRoute { - type Error = WtnsPathRoute; +impl TryInto for WitnessPathRoute { + type Error = WitnessPathRoute; fn try_into(self) -> Result { match self { @@ -206,8 +206,8 @@ impl TryInto for WtnsPathRoute { } } -impl TryInto for WtnsPathRoute { - type Error = WtnsPathRoute; +impl TryInto for WitnessPathRoute { + type Error = WitnessPathRoute; fn try_into(self) -> Result { match self { @@ -237,14 +237,14 @@ mod test { let witness_types = witness.ty(); let injected_val_tuple = - WtnsInjector::inject_value(&witness, witness_types, &["Left", "0"], Value::u64(3)).unwrap(); + WitnessInjector::inject_value(&witness, witness_types, &["Left", "0"], Value::u64(3)).unwrap(); assert_eq!( injected_val_tuple, Value::parse_from_str("Left((3, Right(1)))", witness_types).unwrap() ); - let injected_val_either = WtnsInjector::inject_value( + let injected_val_either = WitnessInjector::inject_value( &witness, witness_types, &["Left", "1"], @@ -263,9 +263,9 @@ mod test { let witness = Arc::new(dummy_value()); let witness_types = witness.ty(); - let err = WtnsInjector::inject_value(&witness, witness_types, &["Left", "5"], Value::u64(0)).unwrap_err(); + let err = WitnessInjector::inject_value(&witness, witness_types, &["Left", "5"], Value::u64(0)).unwrap_err(); - assert!(matches!(err, WtnsWrappingError::IdxOutOfBounds(_, _))); + assert!(matches!(err, WitnessWrappingError::IdxOutOfBounds(_, _))); } #[test] @@ -273,9 +273,9 @@ mod test { let witness = Arc::new(dummy_value()); let witness_types = witness.ty(); - let err = WtnsInjector::inject_value(&witness, witness_types, &["Left", "1"], Value::unit()).unwrap_err(); + let err = WitnessInjector::inject_value(&witness, witness_types, &["Left", "1"], Value::unit()).unwrap_err(); - assert!(matches!(err, WtnsWrappingError::RootTypeMismatch(_, _))); + assert!(matches!(err, WitnessWrappingError::RootTypeMismatch(_, _))); } #[test] @@ -283,7 +283,7 @@ mod test { let witness = Arc::new(dummy_value()); let witness_types = witness.ty(); - let err = WtnsInjector::inject_value( + let err = WitnessInjector::inject_value( &witness, witness_types, &["Right"], @@ -297,6 +297,6 @@ mod test { ) .unwrap_err(); - assert!(matches!(err, WtnsWrappingError::EitherBranchMismatch)); + assert!(matches!(err, WitnessWrappingError::EitherBranchMismatch)); } } diff --git a/crates/sdk/src/transaction/error.rs b/crates/sdk/src/transaction/error.rs new file mode 100644 index 00000000..9b138b5f --- /dev/null +++ b/crates/sdk/src/transaction/error.rs @@ -0,0 +1,38 @@ +use simplicityhl::elements::OutPoint; + +/// Errors encountered while assembling or validating a transaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum FinalTransactionError { + /// An input has no known asset or amount and no unblinding secrets. + #[error("Input {input_index} requires an explicit asset and amount or unblinding secrets")] + MissingInputSecrets { + /// The index of the unresolved input in the transaction. + input_index: usize, + }, + /// An issuance is invalid. + #[error("Input {input_index} doesn't issue anything")] + EmptyIssuance { + /// The index of the issuance input in the transaction. + input_index: usize, + }, + /// A reissuance spends a token without a non-zero asset blinding factor. + #[error("Input {input_index} reissues with a token that isn't blinded")] + UnblindedReissuanceToken { + /// The index of the reissuance input in the transaction. + input_index: usize, + }, + /// The difference of the policy asset amounts of the inputs and outputs overflows `i64`. + #[error("The policy asset amounts overflow the fee delta")] + AmountOverflow, +} + +/// Errors encountered when reading a UTXO's asset or amount. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum UtxoError { + /// Neither an explicit asset ID nor unblinding secrets are available. + #[error("UTXO {0} has no explicit asset or unblinding secrets")] + UnknownAsset(OutPoint), + /// Neither an explicit amount nor unblinding secrets are available. + #[error("UTXO {0} has no explicit amount or unblinding secrets")] + UnknownAmount(OutPoint), +} diff --git a/crates/sdk/src/transaction/final_transaction.rs b/crates/sdk/src/transaction/final_transaction.rs index 4928560a..d1488c55 100644 --- a/crates/sdk/src/transaction/final_transaction.rs +++ b/crates/sdk/src/transaction/final_transaction.rs @@ -2,30 +2,17 @@ use std::collections::HashMap; use simplicityhl::elements::pset::{Input, PartiallySignedTransaction}; use simplicityhl::elements::{ - AssetBlindingNonce, AssetEntropy, AssetId, LockTime, Sequence, TxOutSecrets, + AssetBlindingNonce, AssetId, LockTime, Sequence, TxOutSecrets, confidential::{AssetBlindingFactor, ValueBlindingFactor}, }; use crate::provider::SimplicityNetwork; -use crate::utils; use super::change_output::ChangeOutput; -use super::partial_input::{IssuanceInput, PartialInput, ProgramInput, RequiredSignature}; +use super::error::FinalTransactionError; +use super::partial_input::{IssuanceDetails, IssuanceInput, PartialInput, ProgramInput}; use super::partial_output::PartialOutput; - -/// Constant is defined for fee calculation on transaction sending. -pub const WITNESS_SCALE_FACTOR: usize = 4; - -/// A structure representing the details of token issuance and related metadata. -#[derive(Debug, Clone)] -pub struct IssuanceDetails { - /// The unique `AssetId` generated from the provided entropy, representing the issued tokens struct. - pub asset_id: AssetId, - /// The `AssetId` corresponding to the reissuance (inflation) token, used for minting new tokens. - pub inflation_asset_id: AssetId, - /// The entropy value that was used to derive both the `asset_id` and `inflation_asset_id`. - pub asset_entropy: AssetEntropy, -} +use super::required_signature::{NativeSignature, ProgramSignature, RequiredSignature}; /// Represents the final input structure put into a `FinalTransaction` for processing. #[derive(Clone, Debug)] @@ -76,26 +63,18 @@ impl FinalInput { /// input is available. #[must_use] pub fn get_issuance_details(&self) -> Option { - match &self.issuance_input { - Some(issuance_input) => { - let asset_entropy = match issuance_input { - IssuanceInput::Issuance { asset_entropy, .. } => { - utils::asset_entropy(&self.partial_input.outpoint(), *asset_entropy) - } - IssuanceInput::Reissuance { asset_entropy, .. } => AssetEntropy::from_byte_array(*asset_entropy), - }; - - let asset_id = AssetId::from_entropy(asset_entropy); - let inflation_asset_id = AssetId::reissuance_token_from_entropy(asset_entropy, false); + self.issuance_input + .as_ref() + .map(|issuance_input| issuance_input.details(&self.partial_input.outpoint)) + } - Some(IssuanceDetails { - asset_id, - inflation_asset_id, - asset_entropy, - }) - } - None => None, - } + /// Returns the input's asset ID and amount from unblinding secrets, or from + /// explicit fields when no secrets are available. + /// + /// Returns `None` if no secrets are available and either explicit field is missing. + #[must_use] + pub fn get_asset_and_amount(&self) -> Option<(AssetId, u64)> { + self.partial_input.asset_and_amount() } /// Converts the current object into an `Input` representation, including any @@ -133,6 +112,28 @@ impl FinalInput { pst_input } + + fn check_issuance(&self, input_index: usize) -> Result<(), FinalTransactionError> { + match &self.issuance_input { + Some( + IssuanceInput::Issuance { + issuance_amount: 0, + inflation_amount: 0, + .. + } + | IssuanceInput::Reissuance { issuance_amount: 0, .. }, + ) => Err(FinalTransactionError::EmptyIssuance { input_index }), + Some(IssuanceInput::Reissuance { .. }) + if self + .partial_input + .secrets + .is_none_or(|secrets| secrets.asset_bf == AssetBlindingFactor::zero()) => + { + Err(FinalTransactionError::UnblindedReissuanceToken { input_index }) + } + _ => Ok(()), + } + } } /// A struct representing a final (but not yet signed) transaction. @@ -141,7 +142,7 @@ pub struct FinalTransaction { inputs: Vec, outputs: Vec, change: Option, - sequence: Sequence, + sequence: Option, locktime: LockTime, } @@ -154,15 +155,16 @@ impl FinalTransaction { inputs: Vec::new(), outputs: Vec::new(), change: None, - sequence: Sequence::default(), + sequence: None, locktime: LockTime::ZERO, } } + /// Sets a specific `Sequence` for the transaction. /// /// Injects this value into the inputs that don't declare their own sequence. pub fn set_sequence(&mut self, sequence: Sequence) { - self.sequence = sequence; + self.sequence = Some(sequence); } /// Sets a specific `LockTime` for the transaction. @@ -172,6 +174,23 @@ impl FinalTransaction { self.locktime = locktime; } + /// The sequence of the inputs that don't declare their own. The one set with `set_sequence`, + /// or `ENABLE_LOCKTIME_NO_RBF` when a locktime is set to be enforced, and `MAX` otherwise. + #[must_use] + pub fn sequence(&self) -> Sequence { + self.sequence.unwrap_or(if self.has_locktime() { + Sequence::ENABLE_LOCKTIME_NO_RBF + } else { + Sequence::MAX + }) + } + + /// The locktime of the the transaction, e.g. inputs that don't declare their own. + #[must_use] + pub fn locktime(&self) -> LockTime { + self.locktime + } + /// Sets where this transaction's change should go. /// /// Left unset, the signer sends change to the single address it derives internally. @@ -184,97 +203,59 @@ impl FinalTransaction { self.change = None; } - /// Adds a new input to the transaction. - /// - /// # Panics - /// Panics if the requested signature is not `NativeEcdsa` or `None`. - /// (i.e. if `required_sig` is `RequiredSignature::Witness` or `RequiredSignature::WitnessWithPath`) - pub fn add_input(&mut self, partial_input: PartialInput, required_sig: RequiredSignature) { - match required_sig { - RequiredSignature::Witness(_) - | RequiredSignature::WitnessWithPath(_, _) - | RequiredSignature::WitnessWithMessage(_, _, _) => { - panic!("Requested signature is not NativeEcdsa or None") - } - _ => {} - } - - self.push_new_input(FinalInput::new(partial_input, required_sig)); + /// Adds a native input to the transaction. + pub fn add_input(&mut self, partial_input: PartialInput, signature: Option) { + self.inputs.push(FinalInput::new(partial_input, signature.into())); } - /// Adds a new program input to the transaction. - /// - /// # Panics - /// The function will panic if the `required_sig` parameter is of type `RequiredSignature::NativeEcdsa`, - /// as this type of signature is not applicable for program inputs. + /// Adds a program input to the transaction, with the signature the signer injects into its witness, if any. pub fn add_program_input( &mut self, partial_input: PartialInput, program_input: ProgramInput, - required_sig: RequiredSignature, + signature: Option, ) { - if let RequiredSignature::NativeEcdsa = required_sig { - panic!("Requested signature is not Witness or None"); - } - - self.push_new_input(FinalInput::new(partial_input, required_sig).with_program(program_input)); + self.inputs + .push(FinalInput::new(partial_input, signature.into()).with_program(program_input)); } - /// Adds an issuance (or reissuance) input to the transaction. - /// - /// # Panics - /// This function panics if the `required_sig` is of type `Witness` or - /// `WitnessWithPath`, as these signature types are not allowed in the current context. + /// Adds a native issuance (or reissuance) input to the transaction. pub fn add_issuance_input( &mut self, partial_input: PartialInput, issuance_input: IssuanceInput, - required_sig: RequiredSignature, + signature: Option, ) -> IssuanceDetails { - match required_sig { - RequiredSignature::Witness(_) - | RequiredSignature::WitnessWithPath(_, _) - | RequiredSignature::WitnessWithMessage(_, _, _) => { - panic!("Requested signature is not NativeEcdsa or None") - } - _ => {} - } + let details = issuance_input.details(&partial_input.outpoint); + + self.inputs + .push(FinalInput::new(partial_input, signature.into()).with_issuance(issuance_input)); - self.push_new_input(FinalInput::new(partial_input, required_sig).with_issuance(issuance_input)) - .unwrap() + details } - /// Adds an issuance program input to the transaction with the specified parameters. - /// - /// # Panics - /// Panics if the `required_sig` parameter is of type `RequiredSignature::NativeEcdsa`. - /// Also panics if the populated input fails to return valid issuance details. + /// Adds a program issuance (or reissuance) input to the transaction. pub fn add_program_issuance_input( &mut self, partial_input: PartialInput, program_input: ProgramInput, issuance_input: IssuanceInput, - required_sig: RequiredSignature, + signature: Option, ) -> IssuanceDetails { - if let RequiredSignature::NativeEcdsa = required_sig { - panic!("Requested signature is not Witness or None"); - } + let details = issuance_input.details(&partial_input.outpoint); - self.push_new_input( - FinalInput::new(partial_input, required_sig) + self.inputs.push( + FinalInput::new(partial_input, signature.into()) .with_program(program_input) .with_issuance(issuance_input), - ) - .unwrap() + ); + + details } - /// Removes an input from the list of inputs at the specified index. + /// Removes the input at `index`, `None` when there is none. pub fn remove_input(&mut self, index: usize) -> Option { - if self.inputs.get(index).is_some() { - return Some(self.inputs.remove(index)); - } - - None + (index < self.inputs.len()).then(|| self.inputs.remove(index)) } /// Adds a partial output to the list of outputs. @@ -282,16 +263,9 @@ impl FinalTransaction { self.outputs.push(partial_output); } - /// Removes an output from the `outputs` list at the specified index. - /// - /// # Panics - /// This function does not panic. If the `index` is invalid, it will return `None` instead of causing a panic. + /// Removes the output at `index`, `None` when there is none. pub fn remove_output(&mut self, index: usize) -> Option { - if self.outputs.get(index).is_some() { - return Some(self.outputs.remove(index)); - } - - None + (index < self.outputs.len()).then(|| self.outputs.remove(index)) } /// Where this transaction's change should go, when the caller said. @@ -369,36 +343,30 @@ impl FinalTransaction { /// The function considers the network's policy asset to determine which inputs /// and outputs contribute to the calculation. /// - /// # Panics - /// Function will panic if the asset isn't unblinded correctly, and if PST input asset and amount is confidential. - #[must_use] - pub fn calculate_fee_delta(&self, network: &SimplicityNetwork) -> i64 { - let mut available_amount = 0; - - for input in &self.inputs { - match input.partial_input.secrets { - // This is an unblinded confidential input - Some(secrets) => { - if secrets.asset == network.policy_asset() { - available_amount += secrets.value; - } - } - // This is an explicit input - None => { - if input.partial_input.asset.unwrap() == network.policy_asset() { - available_amount += input.partial_input.amount.unwrap(); - } - } + /// # Errors + /// Returns [`FinalTransactionError::MissingInputSecrets`] if an input's asset or + /// amount is unknown and no unblinding secrets are available. + /// Returns [`FinalTransactionError::AmountOverflow`] if the delta doesn't fit `i64`. + pub fn calculate_fee_delta(&self, network: &SimplicityNetwork) -> Result { + let mut available_amount: i128 = 0; + + for (input_index, input) in self.inputs.iter().enumerate() { + let (asset, amount) = input + .get_asset_and_amount() + .ok_or(FinalTransactionError::MissingInputSecrets { input_index })?; + if asset == network.policy_asset() { + available_amount += i128::from(amount); } } - let consumed_amount = self + let consumed_amount: i128 = self .outputs .iter() .filter(|output| output.asset == network.policy_asset()) - .fold(0_u64, |acc, output| acc + output.amount); + .map(|output| i128::from(output.amount)) + .sum(); - available_amount.cast_signed() - consumed_amount.cast_signed() + i64::try_from(available_amount - consumed_amount).map_err(|_| FinalTransactionError::AmountOverflow) } /// Checks if the transaction is balanced, meaning all inputs - all outputs = 0 for every asset. @@ -406,21 +374,20 @@ impl FinalTransaction { /// Issued and reissued amounts are credited to the input that declares them, so a newly created /// asset is balanced against its declared `issuance_amount`/`inflation_amount`. /// - /// # Panics - /// Function will panic if the assets aren't unblinded correctly, and if PST input assets and amounts are confidential. - #[must_use] - pub fn is_balanced(&self) -> bool { - let mut transfers: HashMap = HashMap::new(); + /// # Errors + /// Returns [`FinalTransactionError::MissingInputSecrets`] if an input's asset or + /// amount is unknown and no unblinding secrets are available. + pub fn is_balanced(&self) -> Result { + let mut transfers: HashMap = HashMap::new(); // Collecting all inputs - for input in &self.inputs { - let (asset, amount) = match input.partial_input.secrets { - Some(secrets) => (secrets.asset, secrets.value), - None => (input.partial_input.asset.unwrap(), input.partial_input.amount.unwrap()), - }; + for (input_index, input) in self.inputs.iter().enumerate() { + let (asset, amount) = input + .get_asset_and_amount() + .ok_or(FinalTransactionError::MissingInputSecrets { input_index })?; let transfer_entry = transfers.entry(asset).or_insert(0); - *transfer_entry += amount.cast_signed(); + *transfer_entry += i128::from(amount); // Issuance brings new assets into the transaction, so the declared amounts count as inputs. // A reissuance mints no new inflation tokens @@ -436,8 +403,8 @@ impl FinalTransaction { IssuanceInput::Reissuance { issuance_amount, .. } => (*issuance_amount, 0), }; - *transfers.entry(issuance_details.asset_id).or_insert(0) += issuance_amount.cast_signed(); - *transfers.entry(issuance_details.inflation_asset_id).or_insert(0) += inflation_amount.cast_signed(); + *transfers.entry(issuance_details.asset_id).or_insert(0) += i128::from(issuance_amount); + *transfers.entry(issuance_details.inflation_asset_id).or_insert(0) += i128::from(inflation_amount); } } @@ -448,98 +415,82 @@ impl FinalTransaction { match transfers.get_mut(&output.asset) { Some(value) => { - *value -= output.amount.cast_signed(); + *value -= i128::from(output.amount); } - None => return false, + None => return Ok(false), } } // All transfers including fee should sum up to 0 - transfers.values().all(|&value| value == 0) - } - - /// Computes the transaction fee based on the provided weight and fee rate. - /// - /// Overall, the function calculates the virtual size (vsize) of the transaction as: - /// `weight / WITNESS_SCALE_FACTOR`, rounded up to the nearest whole number. - /// Then, the fee is computed as `(vsize * fee_rate / 1000.0)`, also rounded up. - /// - /// # Returns - /// The transaction fee in satoshis, rounded up to the nearest whole number. - #[allow( - clippy::cast_possible_truncation, - clippy::cast_precision_loss, - clippy::cast_sign_loss - )] - #[must_use] - pub fn calculate_fee(&self, weight: usize, fee_rate: f32) -> u64 { - let vsize = weight.div_ceil(WITNESS_SCALE_FACTOR); - - (vsize as f32 * fee_rate / 1000.0).ceil() as u64 + Ok(transfers.values().all(|&value| value == 0)) } /// Extracts a partially signed transaction (PST) and a mapping of input secrets from the current state. /// - /// # Panics - /// Function will panic if the pst input is a confidential issuance. - #[must_use] - pub fn extract_pst(&self) -> (PartiallySignedTransaction, HashMap) { + /// # Errors + /// Returns [`FinalTransactionError::MissingInputSecrets`] if an input's asset or + /// amount is unknown and no unblinding secrets are available. + /// + /// Returns [`FinalTransactionError::EmptyIssuance`] if an issuance input doesn't issue anything, and + /// [`FinalTransactionError::UnblindedReissuanceToken`] if a reissuance spends a token that isn't blinded. + pub fn extract_pst( + &self, + ) -> Result<(PartiallySignedTransaction, HashMap), FinalTransactionError> { let mut input_secrets = HashMap::new(); let mut pst = PartiallySignedTransaction::new_v2(); + let sequence = self.sequence(); for i in 0..self.inputs.len() { let mut final_input = self.inputs[i].clone(); - // Inject sequence if the input has none - if final_input.partial_input.sequence == Sequence::default() { - final_input.partial_input = final_input.partial_input.with_sequence(self.sequence); - } - - // Inject locktime if the input has none - if final_input.partial_input.locktime == LockTime::ZERO { - final_input.partial_input = final_input.partial_input.with_locktime(self.locktime); - } - - let pst_input = final_input.to_input(); - - match final_input.partial_input.secrets { - // Insert input secrets if present - Some(secrets) => input_secrets.insert(i, secrets), - // Else populate input secrets with "explicit" amounts - None => input_secrets.insert( - i, - TxOutSecrets { - asset: pst_input.asset.unwrap(), - asset_bf: AssetBlindingFactor::zero(), - value: pst_input.amount.unwrap(), - value_bf: ValueBlindingFactor::zero(), - }, - ), + // An input that sets no sequence or locktime takes the transaction's + final_input.partial_input.sequence.get_or_insert(sequence); + final_input.partial_input.locktime.get_or_insert(self.locktime); + + let secrets = if let Some(secrets) = final_input.partial_input.secrets { + secrets + } else { + let (asset, value) = final_input + .partial_input + .asset_and_amount() + .ok_or(FinalTransactionError::MissingInputSecrets { input_index: i })?; + + TxOutSecrets { + asset, + asset_bf: AssetBlindingFactor::zero(), + value, + value_bf: ValueBlindingFactor::zero(), + } }; - pst.add_input(pst_input); + final_input.check_issuance(i)?; + + input_secrets.insert(i, secrets); + pst.add_input(final_input.to_input()); } self.outputs.iter().for_each(|el| { pst.add_output(el.to_output()); }); - (pst, input_secrets) + Ok((pst, input_secrets)) } - fn push_new_input(&mut self, new_input: FinalInput) -> Option { - let issuance_details = new_input.get_issuance_details(); - - self.inputs.push(new_input); - - issuance_details + /// If the transaction or any of its inputs sets a non-zero locktime. + fn has_locktime(&self) -> bool { + self.locktime != LockTime::ZERO + || self.inputs.iter().any(|input| { + input + .partial_input + .locktime + .is_some_and(|locktime| locktime != LockTime::ZERO) + }) } } #[cfg(test)] mod tests { - - use simplicityhl::elements::{LockTime, OutPoint, Script, TxOut, Txid}; + use simplicityhl::elements::{AssetEntropy, LockTime, OutPoint, Script, TxOut, Txid}; use crate::transaction::UTXO; @@ -561,11 +512,7 @@ mod tests { } fn explicit_utxo(txid_byte: u8, vout: u32, amount: u64, asset: AssetId) -> UTXO { - UTXO { - outpoint: OutPoint::new(dummy_txid(txid_byte), vout), - txout: TxOut::new_fee(amount, asset), - secrets: None, - } + UTXO::explicit(OutPoint::new(dummy_txid(txid_byte), vout), Script::new(), amount, asset) } fn confidential_utxo(txid_byte: u8, vout: u32, asset: AssetId, value: u64) -> UTXO { @@ -586,10 +533,7 @@ mod tests { let policy = dummy_asset_id(0xAA); let mut ft = FinalTransaction::new(); - ft.add_input( - PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), - RequiredSignature::None, - ); + ft.add_input(PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), None); assert!(!ft.has_confidential_input()); } @@ -599,10 +543,7 @@ mod tests { let policy = dummy_asset_id(0xAA); let mut ft = FinalTransaction::new(); - ft.add_input( - PartialInput::new(confidential_utxo(0x01, 0, policy, 5000)), - RequiredSignature::None, - ); + ft.add_input(PartialInput::new(confidential_utxo(0x01, 0, policy, 5000)), None); assert!(ft.has_confidential_input()); } @@ -612,10 +553,7 @@ mod tests { let policy = dummy_asset_id(0xAA); let mut ft = FinalTransaction::new(); - ft.add_input( - PartialInput::new(confidential_utxo(0x01, 0, policy, 5000)), - RequiredSignature::None, - ); + ft.add_input(PartialInput::new(confidential_utxo(0x01, 0, policy, 5000)), None); ft.add_output(PartialOutput::new(Script::new(), 4000, policy)); assert!(ft.has_confidential_input()); @@ -627,10 +565,7 @@ mod tests { let policy = dummy_asset_id(0xAA); let mut ft = FinalTransaction::new(); - ft.add_input( - PartialInput::new(confidential_utxo(0x01, 0, policy, 5000)), - RequiredSignature::None, - ); + ft.add_input(PartialInput::new(confidential_utxo(0x01, 0, policy, 5000)), None); ft.add_output(PartialOutput::new(Script::new(), 4000, policy).with_blinding_key(dummy_blinding_key())); assert!(ft.has_confidential_input()); @@ -647,7 +582,7 @@ mod tests { let partial_output = PartialOutput::new(Script::new(), 4000, policy); let mut ft = FinalTransaction::new(); - ft.add_input(partial_input.clone(), RequiredSignature::None); + ft.add_input(partial_input.clone(), None); ft.add_output(partial_output.clone()); let mut expected_pst = PartiallySignedTransaction::new_v2(); @@ -659,7 +594,7 @@ mod tests { TxOutSecrets::new(policy, AssetBlindingFactor::zero(), 5000, ValueBlindingFactor::zero()), )]); - let (pst, secrets) = ft.extract_pst(); + let (pst, secrets) = ft.extract_pst().unwrap(); assert_eq!(pst, expected_pst); assert_eq!(secrets, expected_secrets); @@ -670,28 +605,35 @@ mod tests { let policy = dummy_asset_id(0xAA); let mut ft = FinalTransaction::new(); - ft.add_input( - PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), - RequiredSignature::None, - ); - ft.add_input( - PartialInput::new(explicit_utxo(0x02, 0, 5000, policy)), - RequiredSignature::None, - ); + ft.add_input(PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), None); + ft.add_input(PartialInput::new(explicit_utxo(0x02, 0, 5000, policy)), None); ft.add_output(PartialOutput::new(Script::new(), 9000, policy)); ft.set_locktime(LockTime::from_height(2_580_990).unwrap()); - let (pst, _) = ft.extract_pst(); + let (pst, _) = ft.extract_pst().unwrap(); assert_eq!( pst.locktime().expect("one height, so no conflict"), LockTime::from_height(2_580_990).unwrap() ); - assert!( - pst.inputs() - .iter() - .all(|input| input.required_height_locktime.is_some()) - ); + assert!(pst.inputs().iter().all(|input| { + input.required_height_locktime.is_some() && input.sequence == Some(Sequence::ENABLE_LOCKTIME_NO_RBF) + })); + } + + #[test] + fn explicit_final_sequence_is_kept_under_a_locktime() { + let policy = dummy_asset_id(0xAA); + let mut ft = FinalTransaction::new(); + + ft.add_input(PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), None); + ft.add_output(PartialOutput::new(Script::new(), 5000, policy)); + ft.set_locktime(LockTime::from_height(100).unwrap()); + ft.set_sequence(Sequence::MAX); + + let (pst, _) = ft.extract_pst().unwrap(); + + assert_eq!(pst.inputs()[0].sequence, Some(Sequence::MAX)); } #[test] @@ -699,15 +641,13 @@ mod tests { let policy = dummy_asset_id(0xAA); let mut ft = FinalTransaction::new(); - ft.add_input( - PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), - RequiredSignature::None, - ); + ft.add_input(PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), None); ft.add_output(PartialOutput::new(Script::new(), 4000, policy)); - let (pst, _) = ft.extract_pst(); + let (pst, _) = ft.extract_pst().unwrap(); assert_eq!(pst.locktime().unwrap(), LockTime::ZERO); + assert_eq!(pst.inputs()[0].sequence, Some(Sequence::MAX)); } #[test] @@ -719,7 +659,7 @@ mod tests { let partial_output = PartialOutput::new(Script::new(), 2000, policy); let mut ft = FinalTransaction::new(); - ft.add_input(partial_input.clone(), RequiredSignature::None); + ft.add_input(partial_input.clone(), None); ft.add_output(partial_output.clone()); let mut expected_pst = PartiallySignedTransaction::new_v2(); @@ -731,7 +671,7 @@ mod tests { TxOutSecrets::new(policy, AssetBlindingFactor::zero(), 3000, ValueBlindingFactor::zero()), )]); - let (pst, secrets) = ft.extract_pst(); + let (pst, secrets) = ft.extract_pst().unwrap(); assert_eq!(pst, expected_pst); assert_eq!(secrets, expected_secrets); @@ -752,8 +692,8 @@ mod tests { let output_b = PartialOutput::new(Script::new(), 800, other); let mut ft = FinalTransaction::new(); - ft.add_input(explicit_partial.clone(), RequiredSignature::None); - ft.add_input(conf_partial.clone(), RequiredSignature::None); + ft.add_input(explicit_partial.clone(), None); + ft.add_input(conf_partial.clone(), None); ft.add_output(output_a.clone()); ft.add_output(output_b.clone()); @@ -774,7 +714,7 @@ mod tests { ), ]); - let (pst, secrets) = ft.extract_pst(); + let (pst, secrets) = ft.extract_pst().unwrap(); assert_eq!(pst, expected_pst); assert_eq!(secrets, expected_secrets); @@ -792,7 +732,7 @@ mod tests { let partial_output = PartialOutput::new(Script::new(), 4000, policy); let mut ft = FinalTransaction::new(); - ft.add_issuance_input(partial_input.clone(), issuance.clone(), RequiredSignature::None); + ft.add_issuance_input(partial_input.clone(), issuance.clone(), None); ft.add_output(partial_output.clone()); // build expected pst, merge partial_input and issuance manually @@ -812,7 +752,7 @@ mod tests { TxOutSecrets::new(policy, AssetBlindingFactor::zero(), 5000, ValueBlindingFactor::zero()), )]); - let (pst, secrets) = ft.extract_pst(); + let (pst, secrets) = ft.extract_pst().unwrap(); assert_eq!(pst, expected_pst); assert_eq!(secrets, expected_secrets); @@ -824,13 +764,22 @@ mod tests { let entropy = [0x42u8; 32]; let issuance_amount = 1_000_000u64; - let conf_utxo = confidential_utxo(0x02, 0, policy, 1000); + // A reissuance token needs a non-zero asset blinding factor + let token_secrets = TxOutSecrets::new( + policy, + AssetBlindingFactor::from_slice(&[1; 32]).unwrap(), + 1000, + ValueBlindingFactor::zero(), + ); + let mut conf_utxo = confidential_utxo(0x02, 0, policy, 1000); + conf_utxo.secrets = Some(token_secrets); + let partial_input = PartialInput::new(conf_utxo); let reissuance_input = IssuanceInput::new_reissuance(issuance_amount, entropy); let partial_output = PartialOutput::new(Script::new(), 1000, policy); let mut ft = FinalTransaction::new(); - ft.add_issuance_input(partial_input.clone(), reissuance_input.clone(), RequiredSignature::None); + ft.add_issuance_input(partial_input.clone(), reissuance_input.clone(), None); ft.add_output(partial_output.clone()); // build expected pst, merge partial_input and issuance manually @@ -847,12 +796,9 @@ mod tests { expected_pst.add_input(expected_input); expected_pst.add_output(partial_output.to_output()); - let expected_secrets = HashMap::from([( - 0, - TxOutSecrets::new(policy, AssetBlindingFactor::zero(), 1000, ValueBlindingFactor::zero()), - )]); + let expected_secrets = HashMap::from([(0, token_secrets)]); - let (pst, secrets) = ft.extract_pst(); + let (pst, secrets) = ft.extract_pst().unwrap(); assert_eq!(pst, expected_pst); assert_eq!(secrets, expected_secrets); @@ -863,14 +809,11 @@ mod tests { let policy = dummy_asset_id(0xAA); let mut ft = FinalTransaction::new(); - ft.add_input( - PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), - RequiredSignature::None, - ); + ft.add_input(PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), None); ft.add_output(PartialOutput::new(Script::new(), 4000, policy)); ft.add_output(PartialOutput::new(Script::new(), 1000, policy)); - assert!(ft.is_balanced()); + assert!(ft.is_balanced().unwrap()); } #[test] @@ -878,14 +821,11 @@ mod tests { let policy = dummy_asset_id(0xAA); let mut ft = FinalTransaction::new(); - ft.add_input( - PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), - RequiredSignature::None, - ); + ft.add_input(PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), None); ft.add_output(PartialOutput::new(Script::new(), 5000, policy)); ft.add_output(PartialOutput::new(Script::new(), 1, dummy_asset_id(0xBB))); - assert!(!ft.is_balanced()); + assert!(!ft.is_balanced().unwrap()); } #[test] @@ -893,13 +833,10 @@ mod tests { let policy = dummy_asset_id(0xAA); let mut ft = FinalTransaction::new(); - ft.add_input( - PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), - RequiredSignature::None, - ); + ft.add_input(PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), None); ft.add_output(PartialOutput::new(Script::new(), 4000, policy)); - assert!(!ft.is_balanced()); + assert!(!ft.is_balanced().unwrap()); } #[test] @@ -910,13 +847,13 @@ mod tests { let details = ft.add_issuance_input( PartialInput::new(explicit_utxo(0x01, 0, 1000, policy)), IssuanceInput::new_issuance(100, 1, [0x07; 32]), - RequiredSignature::None, + None, ); ft.add_output(PartialOutput::new(Script::new(), 100, details.asset_id)); ft.add_output(PartialOutput::new(Script::new(), 1, details.inflation_asset_id)); ft.add_output(PartialOutput::new(Script::new(), 1000, policy)); - assert!(ft.is_balanced()); + assert!(ft.is_balanced().unwrap()); } #[test] @@ -927,12 +864,12 @@ mod tests { let details = ft.add_issuance_input( PartialInput::new(explicit_utxo(0x01, 0, 1000, policy)), IssuanceInput::new_issuance(100, 0, [0x07; 32]), - RequiredSignature::None, + None, ); ft.add_output(PartialOutput::new(Script::new(), 1_000_000_000, details.asset_id)); ft.add_output(PartialOutput::new(Script::new(), 1000, policy)); - assert!(!ft.is_balanced()); + assert!(!ft.is_balanced().unwrap()); } #[test] @@ -943,13 +880,13 @@ mod tests { let details = ft.add_issuance_input( PartialInput::new(explicit_utxo(0x01, 0, 1000, policy)), IssuanceInput::new_issuance(100, 0, [0x07; 32]), - RequiredSignature::None, + None, ); ft.add_output(PartialOutput::new(Script::new(), 100, details.asset_id)); ft.add_output(PartialOutput::new(Script::new(), 42, details.inflation_asset_id)); ft.add_output(PartialOutput::new(Script::new(), 1000, policy)); - assert!(!ft.is_balanced()); + assert!(!ft.is_balanced().unwrap()); } #[test] @@ -961,29 +898,26 @@ mod tests { let details = probe.add_issuance_input( PartialInput::new(explicit_utxo(0x01, 0, 1, dummy_asset_id(0xBB))), IssuanceInput::new_reissuance(100, entropy), - RequiredSignature::None, + None, ); let mut ft = FinalTransaction::new(); ft.add_input( PartialInput::new(explicit_utxo(0x02, 0, 1, details.inflation_asset_id)), - RequiredSignature::None, + None, ); ft.add_issuance_input( PartialInput::new(explicit_utxo(0x03, 0, 50, details.asset_id)), IssuanceInput::new_reissuance(100, entropy), - RequiredSignature::None, - ); - ft.add_input( - PartialInput::new(explicit_utxo(0x04, 0, 1000, policy)), - RequiredSignature::None, + None, ); + ft.add_input(PartialInput::new(explicit_utxo(0x04, 0, 1000, policy)), None); ft.add_output(PartialOutput::new(Script::new(), 150, details.asset_id)); ft.add_output(PartialOutput::new(Script::new(), 1, details.inflation_asset_id)); ft.add_output(PartialOutput::new(Script::new(), 1000, policy)); - assert!(ft.is_balanced()); + assert!(ft.is_balanced().unwrap()); } #[test] @@ -991,13 +925,10 @@ mod tests { let policy = dummy_asset_id(0xAA); let mut ft = FinalTransaction::new(); - ft.add_input( - PartialInput::new(confidential_utxo(0x01, 0, policy, 5000)), - RequiredSignature::None, - ); + ft.add_input(PartialInput::new(confidential_utxo(0x01, 0, policy, 5000)), None); ft.add_output(PartialOutput::new(Script::new(), 5000, policy)); - assert!(ft.is_balanced()); + assert!(ft.is_balanced().unwrap()); } #[test] @@ -1005,14 +936,106 @@ mod tests { let policy = dummy_asset_id(0xAA); let mut ft = FinalTransaction::new(); - ft.add_input( - PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), - RequiredSignature::None, - ); + ft.add_input(PartialInput::new(explicit_utxo(0x01, 0, 5000, policy)), None); ft.add_output(PartialOutput::new(Script::new(), 4000, policy)); ft.add_output(PartialOutput::new(Script::new(), 1000, policy)); ft.add_output(PartialOutput::new_metadata("burn".as_bytes())); - assert!(ft.is_balanced()); + assert!(ft.is_balanced().unwrap()); + } + + #[test] + fn amounts_beyond_i64_do_not_wrap() { + let policy = dummy_asset_id(0xAA); + let network = SimplicityNetwork::ElementsRegtest { policy_asset: policy }; + let mut ft = FinalTransaction::new(); + + ft.add_input(PartialInput::new(explicit_utxo(0x01, 0, 100_000, policy)), None); + ft.add_output(PartialOutput::new(Script::new(), u64::MAX, policy)); + + assert_eq!( + ft.calculate_fee_delta(&network), + Err(FinalTransactionError::AmountOverflow) + ); + assert_eq!(ft.is_balanced(), Ok(false)); + } + + #[test] + fn reissuance_with_an_explicit_token_is_rejected() { + let entropy = [0x42; 32]; + let token = AssetId::reissuance_token_from_entropy(AssetEntropy::from_byte_array(entropy), false); + + let mut tx = FinalTransaction::new(); + tx.add_issuance_input( + PartialInput::new(explicit_utxo(1, 0, 1, token)), + IssuanceInput::new_reissuance(5_000, entropy), + None, + ); + + assert!(matches!( + tx.extract_pst(), + Err(FinalTransactionError::UnblindedReissuanceToken { input_index: 0 }) + )); + } + + #[test] + fn token_only_issuance_has_a_null_amount_and_an_empty_one_is_rejected() { + let policy = dummy_asset_id(0xAA); + let issuance = |issuance_amount, inflation_amount| { + let mut tx = FinalTransaction::new(); + tx.add_issuance_input( + PartialInput::new(explicit_utxo(1, 0, 1000, policy)), + IssuanceInput::new_issuance(issuance_amount, inflation_amount, [0x42; 32]), + None, + ); + + tx.extract_pst() + }; + + let (pst, _) = issuance(0, 1).unwrap(); + + assert_eq!(pst.inputs()[0].issuance_value_amount, None); + assert_eq!(pst.inputs()[0].issuance_inflation_keys, Some(1)); + assert!(matches!( + issuance(0, 0), + Err(FinalTransactionError::EmptyIssuance { input_index: 0 }) + )); + } + + #[test] + fn reissuance_with_secrets_preserves_the_blinding_nonce() { + use simplicityhl::elements::confidential::{Asset, Value}; + + let entropy = [0x12; 32]; + let token = AssetId::reissuance_token_from_entropy(AssetEntropy::from_byte_array(entropy), false); + + // A nonzero asset blinder distinguishes reissuance from a new issuance. + let asset_bf = AssetBlindingFactor::from_slice(&[1; 32]).unwrap(); + let value_bf = ValueBlindingFactor::from_slice(&[2; 32]).unwrap(); + let expected_secrets = TxOutSecrets::new(token, asset_bf, 1, value_bf); + + let secp = simplicityhl::elements::secp256k1_zkp::Secp256k1::new(); + + let input = PartialInput::new(UTXO { + outpoint: OutPoint::new(dummy_txid(1), 0), + txout: TxOut { + asset: Asset::new_confidential(&secp, token, asset_bf), + value: Value::new_confidential_from_assetid(&secp, 1, token, value_bf, asset_bf), + ..Default::default() + }, + secrets: Some(expected_secrets), + }); + + let mut tx = FinalTransaction::new(); + tx.add_issuance_input(input, IssuanceInput::new_reissuance(5_000, entropy), None); + + let (pst, secrets) = tx.extract_pst().unwrap(); + + assert_eq!(secrets.get(&0), Some(&expected_secrets)); + assert_eq!(pst.inputs()[0].issuance_value_amount, Some(5_000)); + assert_eq!( + pst.inputs()[0].issuance_blinding_nonce, + Some(AssetBlindingNonce::from_blinding_factor(asset_bf)) + ); } } diff --git a/crates/sdk/src/transaction/mod.rs b/crates/sdk/src/transaction/mod.rs index 944c3f3c..430c502a 100644 --- a/crates/sdk/src/transaction/mod.rs +++ b/crates/sdk/src/transaction/mod.rs @@ -1,18 +1,24 @@ /// Represents outputs under construction before transaction finalization. pub mod change_output; +/// Errors for transaction accounting and UTXO data access. +pub mod error; /// 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; mod partial_output; +/// Describes the signatures the signer puts on inputs. +pub mod required_signature; /// 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, SigMessage}; +pub use error::{FinalTransactionError, UtxoError}; +pub use final_transaction::{FinalInput, FinalTransaction}; +pub use partial_input::{IssuanceDetails, PartialInput, ProgramInput}; pub use partial_output::PartialOutput; +pub use required_signature::{NativeSignature, ProgramSignature, RequiredSignature, SigMessage}; pub use tx_receipt::TxReceipt; pub use utxo::UTXO; diff --git a/crates/sdk/src/transaction/partial_input.rs b/crates/sdk/src/transaction/partial_input.rs index fdf4f81c..8b2751de 100644 --- a/crates/sdk/src/transaction/partial_input.rs +++ b/crates/sdk/src/transaction/partial_input.rs @@ -1,124 +1,28 @@ -use std::fmt; use std::fmt::{Debug, Formatter}; -use std::sync::Arc; use elements_miniscript::bitcoin::bip32::DerivationPath; use simplicityhl::WitnessValues; -use simplicityhl::elements::confidential::{Asset, Value}; use simplicityhl::elements::pset::Input; -use simplicityhl::elements::{AssetEntropy, AssetId, LockTime, OutPoint, Sequence, TxOut, TxOutSecrets, Txid}; +use simplicityhl::elements::{AssetEntropy, AssetId, LockTime, OutPoint, Sequence, TxOut, TxOutSecrets}; use crate::program::ProgramTrait; -use crate::utils::tagged_hash; +use crate::utils; use super::UTXO; -/// Derives the 32-byte message to sign from the input's `sighash_all`. -pub type SigMessageFn = Arc [u8; 32] + Send + Sync>; - -/// Defines the 32-byte message a witness signature actually covers. -#[derive(Clone)] -pub enum SigMessage { - /// Sign `sighash_all` itself. - Sighash, - /// Sign the BIP-340 tagged hash `sha256(sha256(tag) || sha256(tag) || sighash_all)`. - Tagged(String), - /// Sign whatever the closure derives from `sighash_all`. - Custom(SigMessageFn), -} - -/// Defines the type of signature required for an input. -#[derive(Debug, Clone)] -pub enum RequiredSignature { - /// No signature is required. - None, - /// A standard Native ECDSA (WPKH) signature is required. - NativeEcdsa, - /// A generic witness payload associated with an external name. - Witness(String), - /// A witness payload requiring traversal through a specified path hierarchy. - WitnessWithPath(String, Vec), - /// Like `WitnessWithPath`, but over a message derived from `sighash_all`. - WitnessWithMessage(String, Vec, SigMessage), -} - -impl SigMessage { - /// Derives the message to sign from the input's `sighash_all`. - #[must_use] - pub fn digest(&self, sighash: [u8; 32]) -> [u8; 32] { - match self { - Self::Sighash => sighash, - Self::Tagged(tag) => tagged_hash(tag, &sighash).to_byte_array(), - Self::Custom(derive) => derive(sighash), - } - } -} - -impl fmt::Debug for SigMessage { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Sighash => f.write_str("Sighash"), - Self::Tagged(tag) => f.debug_tuple("Tagged").field(tag).finish(), - Self::Custom(_) => f.write_str("Custom()"), - } - } -} - -impl RequiredSignature { - /// Creates a `WitnessWithPath` requirement using an iterator of path segments. - pub fn witness_with_path(name: &str, path: I) -> Self - where - I: IntoIterator, - I::Item: AsRef, - { - RequiredSignature::WitnessWithPath( - name.to_string(), - path.into_iter().map(|s| s.as_ref().to_string()).collect(), - ) - } - - /// Creates a `WitnessWithMessage` requirement using an iterator of path segments. - pub fn witness_with_message(name: &str, path: I, message: SigMessage) -> Self - where - I: IntoIterator, - I::Item: AsRef, - { - RequiredSignature::WitnessWithMessage( - name.to_string(), - path.into_iter().map(|s| s.as_ref().to_string()).collect(), - message, - ) - } - - /// Creates a requirement for a BIP-340 tagged signature over `tag` and `sighash_all`. - pub fn witness_tagged(name: &str, path: I, tag: &str) -> Self - where - I: IntoIterator, - I::Item: AsRef, - { - Self::witness_with_message(name, path, SigMessage::Tagged(tag.to_string())) - } -} - /// Represents partially prepared input data for Elements transactions. #[derive(Debug, Clone)] pub struct PartialInput { - /// The transaction ID containing the target UTXO being spent. - pub witness_txid: Txid, - /// The output index of the UTXO within the transaction being spent. - pub witness_output_index: u32, + /// The outpoint of the UTXO being spent. + pub outpoint: OutPoint, /// The native transaction output corresponding to the targeted UTXO. pub witness_utxo: TxOut, /// The sequence number indicating transaction replaceability or relative timelocking. - pub sequence: Sequence, - /// Absolute timelock criteria enforced against the input. - pub locktime: LockTime, - /// The explicit amount value in Satoshis for the input, if available. - /// Note: if UTXO is explicit, `amount` and `asset` are `Some`. - pub amount: Option, - /// The explicit `AssetId` being spent by the input, if available. - pub asset: Option, + /// `None` takes the transaction's. + pub sequence: Option, + /// Absolute timelock criteria enforced against the input. `None` takes the transaction's. + pub locktime: Option, /// Optional blinding secrets mapping values and asset states into confidential outputs. /// Note: if UTXO is confidential, `secrets` are `Some`. pub secrets: Option, @@ -140,10 +44,23 @@ pub struct ProgramInput { impl Debug for ProgramInput { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - writeln!(f, "{}", self.witness) + f.debug_struct("ProgramInput") + .field("witness", &self.witness) + .finish_non_exhaustive() } } +/// A structure representing the details of token issuance and related metadata. +#[derive(Debug, Clone)] +pub struct IssuanceDetails { + /// The unique `AssetId` generated from the provided entropy, representing the issued tokens struct. + pub asset_id: AssetId, + /// The `AssetId` corresponding to the reissuance (inflation) token, used for minting new tokens. + pub inflation_asset_id: AssetId, + /// The entropy value that was used to derive both the `asset_id` and `inflation_asset_id`. + pub asset_entropy: AssetEntropy, +} + /// Represents an input designated for asset issuance or reissuance. #[derive(Clone, Debug)] pub enum IssuanceInput { @@ -165,28 +82,22 @@ pub enum IssuanceInput { }, } +impl Default for PartialInput { + /// An input spending the default `UTXO`. + fn default() -> Self { + Self::new(UTXO::default()) + } +} + impl PartialInput { /// Creates a new `PartialInput` from an existing `UTXO`. - /// Extracts explicit value and asset amounts if available. #[must_use] pub fn new(utxo: UTXO) -> Self { - let amount = match utxo.txout.value { - Value::Explicit(value) => Some(value), - _ => None, - }; - let asset = match utxo.txout.asset { - Asset::Explicit(asset) => Some(asset), - _ => None, - }; - Self { - witness_txid: utxo.outpoint.txid, - witness_output_index: utxo.outpoint.vout, + outpoint: utxo.outpoint, witness_utxo: utxo.txout, - sequence: Sequence::default(), - locktime: LockTime::ZERO, - amount, - asset, + sequence: None, + locktime: None, secrets: utxo.secrets, derivation_path: None, } @@ -195,7 +106,7 @@ impl PartialInput { /// 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`. - /// Should be in the form of "m/n". + /// Should be in the form of `m/n`. #[must_use] pub fn with_derivation_path(mut self, derivation_path: DerivationPath) -> Self { self.derivation_path = Some(derivation_path); @@ -206,7 +117,7 @@ impl PartialInput { /// Sets a specific `Sequence` for the input. #[must_use] pub fn with_sequence(mut self, sequence: Sequence) -> Self { - self.sequence = sequence; + self.sequence = Some(sequence); self } @@ -214,44 +125,58 @@ impl PartialInput { /// Sets a specific `LockTime` for the input. #[must_use] pub fn with_locktime(mut self, locktime: LockTime) -> Self { - self.locktime = locktime; + self.locktime = Some(locktime); self } - /// Returns the `OutPoint` corresponding to this input. + /// The asset and amount of the spent UTXO, from its explicit value or its unblinding secrets. + /// `None` for a confidential UTXO without secrets. #[must_use] - pub fn outpoint(&self) -> OutPoint { - OutPoint { - txid: self.witness_txid, - vout: self.witness_output_index, + pub fn asset_and_amount(&self) -> Option<(AssetId, u64)> { + match self.secrets { + Some(secrets) => Some((secrets.asset, secrets.value)), + None => self.explicit_asset().zip(self.explicit_amount()), } } /// Converts this `PartialInput` into a fully formed PSET `Input`. + /// An unset sequence is final and an unset locktime is zero. #[must_use] pub fn to_input(&self) -> Input { - let time_locktime = match self.locktime { + let locktime = self.locktime.unwrap_or(LockTime::ZERO); + + let time_locktime = match locktime { LockTime::Seconds(value) => Some(value), LockTime::Blocks(_) => None, }; - let height_locktime = match self.locktime { + let height_locktime = match locktime { LockTime::Blocks(value) if value.to_consensus_u32() > 0 => Some(value), LockTime::Blocks(_) | LockTime::Seconds(_) => None, }; Input { - previous_txid: self.witness_txid, - previous_output_index: self.witness_output_index, + previous_txid: self.outpoint.txid, + previous_output_index: self.outpoint.vout, witness_utxo: Some(self.witness_utxo.clone()), - sequence: Some(self.sequence), + sequence: Some(self.sequence.unwrap_or_default()), required_time_locktime: time_locktime, required_height_locktime: height_locktime, - amount: self.amount, - asset: self.asset, + amount: self.explicit_amount(), + asset: self.explicit_asset(), ..Default::default() } } + + /// The amount of the spent UTXO, `None` when it is confidential. + fn explicit_amount(&self) -> Option { + self.witness_utxo.value.explicit() + } + + /// The asset of the spent UTXO, `None` when it is confidential. + fn explicit_asset(&self) -> Option { + self.witness_utxo.asset.explicit() + } } impl ProgramInput { @@ -305,62 +230,26 @@ impl IssuanceInput { }; Input { - issuance_value_amount: Some(issuance_amount), + // Elements encodes a zero amount as null, an explicit zero is invalid + issuance_value_amount: (issuance_amount > 0).then_some(issuance_amount), issuance_asset_entropy: Some(AssetEntropy::from_byte_array(asset_entropy)), issuance_inflation_keys: inflation_amount, blinded_issuance: Some(0x00), ..Default::default() } } -} - -#[cfg(test)] -mod tests { - use super::*; - - const SIGHASH: [u8; 32] = [0x11; 32]; - - #[test] - fn sighash_message_is_the_sighash() { - assert_eq!(SigMessage::Sighash.digest(SIGHASH), SIGHASH); - } - - #[test] - fn tagged_message_is_the_tagged_hash() { - assert_eq!( - SigMessage::Tagged("SimplexTag".to_string()).digest(SIGHASH), - tagged_hash("SimplexTag", &SIGHASH).to_byte_array() - ); - } - - #[test] - fn custom_message_receives_the_sighash() { - let message = SigMessage::Custom(Arc::new(|sighash: [u8; 32]| { - let mut digest = sighash; - digest[0] ^= 0xff; - - digest - })); - let mut expected = SIGHASH; - expected[0] ^= 0xff; - - assert_eq!(message.digest(SIGHASH), expected); - } - - #[test] - fn tagged_constructor_builds_a_tagged_message() { - let required = RequiredSignature::witness_tagged("SIGNATURE", ["Left", "1"], "SimplexTag"); - - let RequiredSignature::WitnessWithMessage(name, path, message) = required else { - panic!("expected a WitnessWithMessage requirement"); + /// The assets an issuance spending `outpoint` creates, or a reissuance mints again. + pub(crate) fn details(&self, outpoint: &OutPoint) -> IssuanceDetails { + let asset_entropy = match self { + Self::Issuance { asset_entropy, .. } => utils::asset_entropy(outpoint, *asset_entropy), + Self::Reissuance { asset_entropy, .. } => AssetEntropy::from_byte_array(*asset_entropy), }; - assert_eq!(name, "SIGNATURE"); - assert_eq!(path, ["Left", "1"]); - assert_eq!( - message.digest(SIGHASH), - tagged_hash("SimplexTag", &SIGHASH).to_byte_array() - ); + IssuanceDetails { + asset_id: AssetId::from_entropy(asset_entropy), + inflation_asset_id: AssetId::reissuance_token_from_entropy(asset_entropy, false), + asset_entropy, + } } } diff --git a/crates/sdk/src/transaction/partial_output.rs b/crates/sdk/src/transaction/partial_output.rs index 57b9a9c1..29f10ebc 100644 --- a/crates/sdk/src/transaction/partial_output.rs +++ b/crates/sdk/src/transaction/partial_output.rs @@ -16,6 +16,13 @@ pub struct PartialOutput { pub blinding_key: Option, } +impl Default for PartialOutput { + /// An empty-script output of zero of the default asset, matching the default `UTXO`. + fn default() -> Self { + Self::new(Script::new(), 0, AssetId::default()) + } +} + impl PartialOutput { /// Creates a new `PartialOutput` assigning a base script, amount, and `AssetId`. #[must_use] diff --git a/crates/sdk/src/transaction/required_signature.rs b/crates/sdk/src/transaction/required_signature.rs new file mode 100644 index 00000000..456b4a7c --- /dev/null +++ b/crates/sdk/src/transaction/required_signature.rs @@ -0,0 +1,186 @@ +use std::fmt; +use std::sync::Arc; + +use crate::utils::tagged_hash; + +/// Derives the 32-byte message to sign from the input's `sighash_all`. +pub type SigMessageFn = Arc [u8; 32] + Send + Sync>; + +/// Defines the 32-byte message a witness signature actually covers. +#[derive(Clone)] +pub enum SigMessage { + /// Sign `sighash_all` itself. + Sighash, + /// Sign the BIP-340 tagged hash `sha256(sha256(tag) || sha256(tag) || sighash_all)`. + Tagged(String), + /// Sign whatever the closure derives from `sighash_all`. + Custom(SigMessageFn), +} + +/// The signature the signer puts on a native input. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeSignature { + /// A native ECDSA (WPKH) signature by the signer. + Ecdsa, +} + +/// A signature the signer injects into a program's witness. +#[derive(Debug, Clone)] +pub struct ProgramSignature { + name: String, + path: Vec, + message: SigMessage, +} + +/// The signature an input needs, native or into a program's witness. +#[derive(Debug, Clone)] +pub enum RequiredSignature { + /// No signature is required. + None, + /// A native ECDSA (WPKH) signature is required. + NativeEcdsa, + /// A signature injected into the program's witness. + Program(ProgramSignature), +} + +impl SigMessage { + /// Derives the message to sign from the input's `sighash_all`. + #[must_use] + pub fn digest(&self, sighash: [u8; 32]) -> [u8; 32] { + match self { + Self::Sighash => sighash, + Self::Tagged(tag) => tagged_hash(tag, &sighash).to_byte_array(), + Self::Custom(derive) => derive(sighash), + } + } +} + +impl fmt::Debug for SigMessage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Sighash => f.write_str("Sighash"), + Self::Tagged(tag) => f.debug_tuple("Tagged").field(tag).finish(), + Self::Custom(_) => f.write_str("Custom()"), + } + } +} + +impl ProgramSignature { + /// A signature over `sighash_all`, put into the witness field `name`. + #[must_use] + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + path: Vec::new(), + message: SigMessage::Sighash, + } + } + + /// Puts the signature inside the witness field at `path`, e.g. `["Right", "Left", "0"]`. + #[must_use] + pub fn at_path(mut self, path: I) -> Self + where + I: IntoIterator, + I::Item: AsRef, + { + self.path = path.into_iter().map(|route| route.as_ref().to_string()).collect(); + + self + } + + /// Signs the message `message` derives from `sighash_all` instead of `sighash_all` itself. + #[must_use] + pub fn with_message(mut self, message: SigMessage) -> Self { + self.message = message; + + self + } + + /// Signs the BIP-340 tagged hash over `tag` and `sighash_all`. + #[must_use] + pub fn tagged(self, tag: &str) -> Self { + self.with_message(SigMessage::Tagged(tag.to_string())) + } + + /// The witness field the signature goes into. + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + /// Where inside the witness field the signature goes, empty for the field itself. + #[must_use] + pub fn path(&self) -> &[String] { + &self.path + } + + /// What the signature covers. + #[must_use] + pub fn message(&self) -> &SigMessage { + &self.message + } +} + +impl From> for RequiredSignature { + fn from(signature: Option) -> Self { + match signature { + Some(NativeSignature::Ecdsa) => Self::NativeEcdsa, + None => Self::None, + } + } +} + +impl From> for RequiredSignature { + fn from(signature: Option) -> Self { + signature.map_or(Self::None, Self::Program) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SIGHASH: [u8; 32] = [0x11; 32]; + + #[test] + fn sighash_message_is_the_sighash() { + assert_eq!(SigMessage::Sighash.digest(SIGHASH), SIGHASH); + } + + #[test] + fn tagged_message_is_the_tagged_hash() { + assert_eq!( + SigMessage::Tagged("SimplexTag".to_string()).digest(SIGHASH), + tagged_hash("SimplexTag", &SIGHASH).to_byte_array() + ); + } + + #[test] + fn custom_message_receives_the_sighash() { + let message = SigMessage::Custom(Arc::new(|sighash: [u8; 32]| { + let mut digest = sighash; + digest[0] ^= 0xff; + + digest + })); + + let mut expected = SIGHASH; + expected[0] ^= 0xff; + + assert_eq!(message.digest(SIGHASH), expected); + } + + #[test] + fn tagged_constructor_builds_a_tagged_message() { + let signature = ProgramSignature::new("SIGNATURE") + .at_path(["Left", "1"]) + .tagged("SimplexTag"); + + assert_eq!(signature.name(), "SIGNATURE"); + assert_eq!(signature.path(), ["Left", "1"]); + assert_eq!( + signature.message().digest(SIGHASH), + tagged_hash("SimplexTag", &SIGHASH).to_byte_array() + ); + } +} diff --git a/crates/sdk/src/transaction/utxo.rs b/crates/sdk/src/transaction/utxo.rs index c861a79c..f3669225 100644 --- a/crates/sdk/src/transaction/utxo.rs +++ b/crates/sdk/src/transaction/utxo.rs @@ -1,4 +1,6 @@ -use simplicityhl::elements::{AssetId, OutPoint, TxOut, TxOutSecrets}; +use simplicityhl::elements::{AssetId, OutPoint, Script, TxOut, TxOutSecrets}; + +use super::error::UtxoError; /// Represents an Unspent Transaction Output (UTXO). #[derive(Debug, Clone)] @@ -13,15 +15,36 @@ pub struct UTXO { impl Default for UTXO { fn default() -> Self { + Self::explicit(OutPoint::default(), Script::new(), 0, AssetId::default()) + } +} + +impl UTXO { + /// Creates a UTXO with an explicit amount and asset, locked by `script`. + #[must_use] + pub fn explicit(outpoint: OutPoint, script: Script, amount: u64, asset: AssetId) -> Self { + let mut txout = TxOut::new_fee(amount, asset); + txout.script_pubkey = script; + Self { - outpoint: OutPoint::default(), - txout: TxOut::new_fee(0, AssetId::default()), + outpoint, + txout, secrets: None, } } -} -impl UTXO { + /// Checks if both the asset and the amount are explicit. + #[must_use] + pub fn is_explicit(&self) -> bool { + self.txout.asset.is_explicit() && self.txout.value.is_explicit() + } + + /// Checks if the asset or the amount is blinded. + #[must_use] + pub fn is_confidential(&self) -> bool { + self.txout.asset.is_confidential() || self.txout.value.is_confidential() + } + /// Retrieves the explicit `AssetId` from the transaction output (`txout`). /// /// # Panics @@ -60,29 +83,42 @@ impl UTXO { self.secrets.expect("The UTXO is not unblinded").value } - /// Retrieves the `AssetId` associated with the instance. - #[must_use] - pub fn asset(&self) -> AssetId { + /// Retrieves the asset ID from unblinding secrets or the explicit output field. + /// + /// # Errors + /// Returns `UtxoError::UnknownAsset` if the asset is confidential or null and + /// no unblinding secrets are available. + pub fn asset(&self) -> Result { self.secrets - .map_or_else(|| self.explicit_asset(), |secrets| secrets.asset) + .map(|secrets| secrets.asset) + .or_else(|| self.txout.asset.explicit()) + .ok_or(UtxoError::UnknownAsset(self.outpoint)) } - /// Retrieves the amount associated with the current instance. + /// Retrieves the amount from unblinding secrets or the explicit output field. /// - /// This function returns the `value` from the `secrets` field if it exists. - /// If no `secrets` are present, it falls back to calculating and returning - /// the explicitly defined amount using the `explicit_amount()` method. - #[must_use] - pub fn amount(&self) -> u64 { + /// # Errors + /// Returns `UtxoError::UnknownAmount` if the value is confidential or null and + /// no unblinding secrets are available. + pub fn amount(&self) -> Result { self.secrets - .map_or_else(|| self.explicit_amount(), |secrets| secrets.value) + .map(|secrets| secrets.value) + .or_else(|| self.txout.value.explicit()) + .ok_or(UtxoError::UnknownAmount(self.outpoint)) } } #[cfg(test)] mod tests { - use super::*; + use simplicityhl::elements::Txid; + use simplicityhl::elements::confidential::{Asset, Value}; use simplicityhl::elements::confidential::{AssetBlindingFactor, ValueBlindingFactor}; + use simplicityhl::elements::secp256k1_zkp::Secp256k1; + + use crate::provider::SimplicityNetwork; + use crate::signer::Signer; + + use super::*; #[test] fn explicit_and_unblinded_accessors_select_the_right_values() { @@ -90,15 +126,15 @@ mod tests { let explicit = UTXO { outpoint: OutPoint::default(), txout: TxOut { - asset: simplicityhl::elements::confidential::Asset::Explicit(asset), - value: simplicityhl::elements::confidential::Value::Explicit(42), + asset: Asset::Explicit(asset), + value: Value::Explicit(42), ..Default::default() }, secrets: None, }; - assert_eq!(explicit.asset(), asset); - assert_eq!(explicit.amount(), 42); + assert_eq!(explicit.asset(), Ok(asset)); + assert_eq!(explicit.amount(), Ok(42)); let unblinded = UTXO { outpoint: OutPoint::default(), @@ -113,7 +149,85 @@ mod tests { assert_eq!(unblinded.unblinded_asset(), asset); assert_eq!(unblinded.unblinded_amount(), 99); - assert_eq!(unblinded.asset(), asset); - assert_eq!(unblinded.amount(), 99); + assert_eq!(unblinded.asset(), Ok(asset)); + assert_eq!(unblinded.amount(), Ok(99)); + } + + fn explicit(network: &SimplicityNetwork, amount: u64) -> UTXO { + const MNEMONIC: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + let signer = Signer::from_mnemonic(MNEMONIC, *network); + + UTXO { + outpoint: OutPoint::new(Txid::from_byte_array(Default::default()), 0), + txout: TxOut { + script_pubkey: signer.get_address().script_pubkey(), + ..TxOut::new_fee(amount, network.policy_asset()) + }, + secrets: None, + } + } + + #[test] + fn unresolved_utxo_accessors_do_not_panic() { + let network = SimplicityNetwork::default_regtest(); + + let mixed = { + let asset_bf = AssetBlindingFactor::new(&mut bip39::rand::thread_rng()); + + let mut utxo = explicit(&network, 600); + utxo.txout.asset = Asset::new_confidential(&Secp256k1::new(), network.policy_asset(), asset_bf); + + // An explicit value has no rangeproof to rewind for the asset blinding factor. + assert!(utxo.txout.witness.rangeproof.is_empty()); + + utxo + }; + + let null_asset = { + let mut utxo = explicit(&network, 600); + utxo.txout.asset = Asset::Null; + + utxo + }; + + for utxo in [mixed, null_asset] { + assert_eq!(utxo.asset(), Err(UtxoError::UnknownAsset(utxo.outpoint))); + } + } + + #[test] + fn unresolved_utxo_value_does_not_panic() { + let network = SimplicityNetwork::default_regtest(); + + let mut utxo = explicit(&network, 600); + utxo.txout.value = Value::Null; + + assert_eq!(utxo.amount(), Err(UtxoError::UnknownAmount(utxo.outpoint))); + } + + #[test] + fn known_secrets_resolve_an_asset_only_commitment() { + let asset = AssetId::from_byte_array(Default::default()); + let asset_bf = AssetBlindingFactor::new(&mut bip39::rand::thread_rng()); + + let mut utxo = UTXO { + outpoint: OutPoint::new(Txid::from_byte_array(Default::default()), 0), + txout: TxOut { + asset: Asset::new_confidential(&Secp256k1::new(), asset, asset_bf), + value: Value::Explicit(600), + ..Default::default() + }, + secrets: None, + }; + + // The visible amount cannot reveal the committed asset ID. + assert_eq!(utxo.amount(), Ok(600)); + assert_eq!(utxo.asset(), Err(UtxoError::UnknownAsset(utxo.outpoint))); + + utxo.secrets = Some(TxOutSecrets::new(asset, asset_bf, 600, ValueBlindingFactor::zero())); + assert_eq!(utxo.asset(), Ok(asset)); + assert_eq!(utxo.amount(), Ok(600)); } } diff --git a/crates/sdk/src/utils.rs b/crates/sdk/src/utils.rs index 4073c24f..6bee5820 100644 --- a/crates/sdk/src/utils.rs +++ b/crates/sdk/src/utils.rs @@ -1,5 +1,3 @@ -use sha2::{Digest, Sha256}; - use bip39::{Mnemonic, WordCount}; use simplicityhl::elements::{AssetEntropy, AssetId, ContractHash, OutPoint, Script, encode}; @@ -10,7 +8,7 @@ use simplicityhl::simplicity::hashes::{HashEngine, sha256}; use crate::program::ProgramError; -/// Generates a radom menemonic with 12 words. +/// Generates a random mnemonic with 12 words. /// /// # Panics /// Panics if the underlying mnemonic generation fails (e.g. invalid word count configuration). @@ -63,10 +61,7 @@ pub fn tap_data_hash(data: &[u8]) -> sha256::Hash { /// Computes the SHA-256 hash of a given script and returns the resulting 32-byte array. #[must_use] pub fn hash_script(script: &Script) -> [u8; 32] { - let mut hasher = Sha256::new(); - - sha2::digest::Update::update(&mut hasher, script.as_bytes()); - hasher.finalize().into() + sha256::Hash::hash(script.as_bytes()).to_byte_array() } /// Converts Satoshi amount into BTC. @@ -75,7 +70,7 @@ pub fn sat2btc(sat: u64) -> f64 { bitcoin::Amount::from_sat(sat).to_btc() } -/// Converts BTC amount into Satoshi. +/// Converts a whole BTC amount into Satoshi. #[must_use] pub fn btc2sat(btc: u64) -> u64 { bitcoin::Amount::from_int_btc(btc).to_sat() @@ -123,16 +118,12 @@ mod tests { #[test] fn tagged_hash_doubles_the_tag_digest() { let data = [0x42_u8; 32]; - let tag_digest = Sha256::digest(b"OracleNetworkV1/Price"); - - let mut expected = Sha256::new(); - expected.update(tag_digest); - expected.update(tag_digest); - expected.update(data); + let tag_digest = sha256::Hash::hash(b"OracleNetworkV1/Price").to_byte_array(); + let preimage = [tag_digest.as_slice(), &tag_digest, &data].concat(); assert_eq!( - tagged_hash("OracleNetworkV1/Price", &data).to_byte_array(), - <[u8; 32]>::from(expected.finalize()) + tagged_hash("OracleNetworkV1/Price", &data), + sha256::Hash::hash(&preimage) ); } diff --git a/crates/simplex/Cargo.toml b/crates/simplex/Cargo.toml index a2f4e059..7048fab5 100644 --- a/crates/simplex/Cargo.toml +++ b/crates/simplex/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smplx-std" -version = "0.1.1" +version = "0.2.0" description = "A blazingly-fast, ux-first simplicity development framework" rust-version = "1.91.0" license.workspace = true diff --git a/crates/test/Cargo.toml b/crates/test/Cargo.toml index b15648bf..5d534a2b 100644 --- a/crates/test/Cargo.toml +++ b/crates/test/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smplx-test" -version = "0.1.1" +version = "0.2.0" description = "Simplex test command internal implementation" license.workspace = true edition.workspace = true diff --git a/crates/test/src/context.rs b/crates/test/src/context.rs index e9cbd0df..fedc97cf 100644 --- a/crates/test/src/context.rs +++ b/crates/test/src/context.rs @@ -1,16 +1,13 @@ -use std::fmt::Debug; use std::path::PathBuf; use std::str::FromStr; use proptest::test_runner::{Config as ProptestConfig, FileFailurePersistence, RngSeed}; use electrsd::bitcoind::bitcoincore_rpc::Auth; -use simplicityhl::{Arguments, WitnessValues}; use smplx_regtest::Regtest; use smplx_regtest::client::RegtestClient; use smplx_sdk::global::GlobalConfig; -use smplx_sdk::program::{ProgramFactory, ProgramSchema}; use smplx_sdk::provider::{ ElementsRpc, EsploraProvider, ProviderInfo, ProviderTrait, SimplexProvider, SimplicityNetwork, }; @@ -21,7 +18,7 @@ use crate::config::TestConfig; use crate::error::TestError; use crate::network_utils::NetworkUtils; -use crate::fuzz::{FuzzableProgram, SimplexFuzzEngine}; +use crate::fuzz::{Scenario, SimplexFuzzEngine}; pub use crate::context::sealed::ContextMode; @@ -281,15 +278,11 @@ impl TestContext { } /// Builds a fuzz engine with a fresh copy of the default signer, so it can be called repeatedly. - pub fn engine(&self) -> SimplexFuzzEngine - where - Program: FuzzableProgram + ProgramFactory + ProgramSchema + Clone + 'static, - Args: Into + Debug + Clone + 'static, - Wit: Into + Debug + Clone + 'static, - { + pub fn engine(&self, scenario: Scenario) -> SimplexFuzzEngine { SimplexFuzzEngine::new( self.mode.proptest_config.clone(), self.create_signer(self.config.mnemonic.as_str()), + scenario, ) } } diff --git a/crates/test/src/error.rs b/crates/test/src/error.rs index 3d03fbd3..3e5943f8 100644 --- a/crates/test/src/error.rs +++ b/crates/test/src/error.rs @@ -4,8 +4,6 @@ use smplx_sdk::provider::{NetworkConvertError, ProviderError}; use smplx_regtest::error::RegtestError; -use crate::fuzz::fuzz_transaction::ProgramTarget; - #[derive(thiserror::Error, Debug)] pub enum TestError { #[error(transparent)] @@ -35,24 +33,3 @@ pub enum NetworkUtilsError { #[error("Unsuccessful action completion, err: '{0}'")] UnsuccessfulSync(String), } - -#[derive(thiserror::Error, Debug, PartialEq, Eq)] -pub enum FuzzError { - #[error("At least one program target is required")] - NoProgramTargets, - - #[error("At least one program input target is required")] - NoInputTargets, - - #[error("Duplicate program target: {0:?}")] - DuplicateProgramTarget(ProgramTarget), - - #[error("Program input target index {index} is out of bounds for {input_count} inputs")] - InputTargetOutOfBounds { index: usize, input_count: usize }, - - #[error("Program output target index {index} is out of bounds for {output_count} outputs")] - OutputTargetOutOfBounds { index: usize, output_count: usize }, - - #[error("Fuzz transaction post hook failed: {0}")] - PostHook(String), -} diff --git a/crates/test/src/fuzz/args_strategy/core.rs b/crates/test/src/fuzz/args_strategy/core.rs deleted file mode 100644 index 0d6af5a0..00000000 --- a/crates/test/src/fuzz/args_strategy/core.rs +++ /dev/null @@ -1,83 +0,0 @@ -use std::fmt::Debug; -use std::marker::PhantomData; - -use simplicityhl::{Arguments, WitnessValues}; - -use proptest::prelude::{BoxedStrategy, Strategy}; -use proptest::strategy::ValueTree; - -use crate::fuzz::args_strategy::{Guided, Random}; - -pub struct ArgsStrategyBuilder> { - base_strategy: Base, - _program: PhantomData P>, -} - -impl

ArgsStrategyBuilder

{ - pub fn new() -> Self { - Self::default() - } -} - -impl

Default for ArgsStrategyBuilder

{ - fn default() -> Self { - Self { - base_strategy: Guided::default(), - _program: PhantomData, - } - } -} - -impl ArgsStrategyBuilder { - pub fn with_random(self) -> ArgsStrategyBuilder> { - ArgsStrategyBuilder { - base_strategy: Random::default(), - _program: PhantomData, - } - } - - pub fn with_guided(self) -> ArgsStrategyBuilder> { - ArgsStrategyBuilder { - base_strategy: Guided::default(), - _program: PhantomData, - } - } - - pub fn with_custom_strategy(self, custom_strategy: New) -> ArgsStrategyBuilder - where - New: Strategy + 'static, - { - ArgsStrategyBuilder { - base_strategy: custom_strategy, - _program: PhantomData, - } - } -} - -impl ArgsStrategyBuilder -where - Base: Strategy + 'static, -{ - pub fn build(self) -> BoxedStrategy<(Arguments, WitnessValues)> { - self.base_strategy.boxed() - } -} - -/// A generated case that is never shrunk. -pub struct FixedValueTree(pub T); - -impl ValueTree for FixedValueTree { - type Value = T; - - fn current(&self) -> T { - self.0.clone() - } - - fn simplify(&mut self) -> bool { - false - } - - fn complicate(&mut self) -> bool { - false - } -} diff --git a/crates/test/src/fuzz/args_strategy/mod.rs b/crates/test/src/fuzz/args_strategy/mod.rs deleted file mode 100644 index ee27e4c3..00000000 --- a/crates/test/src/fuzz/args_strategy/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod core; -mod guided; -mod random; - -pub use core::*; -pub use guided::*; -pub use random::*; diff --git a/crates/test/src/fuzz/case.rs b/crates/test/src/fuzz/case.rs new file mode 100644 index 00000000..8626f830 --- /dev/null +++ b/crates/test/src/fuzz/case.rs @@ -0,0 +1,213 @@ +use simplicityhl::elements::locktime::LOCK_TIME_THRESHOLD; +use simplicityhl::elements::{AssetId, Sequence}; + +use smplx_sdk::program::Program; +use smplx_sdk::provider::SimplicityNetwork; +use smplx_sdk::signer::Signer; +use smplx_sdk::transaction::FinalTransaction; +use smplx_sdk::utils::hash_script; + +use crate::fuzz::mutations::{Mutation, UtxoMap}; +use crate::fuzz::program::{FuzzProgram, FuzzProgramTrait}; +use crate::fuzz::scenario::Scenario; + +/// What is expected to happen to the spend. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Expect { + Accept, + Reject, + Any, +} + +/// Read-only view of one fuzz case, given to bindings while it is built and to the spec before it executes. +pub struct FuzzCase<'a> { + scenario: &'a Scenario, + programs: &'a [Program], + transaction: &'a FinalTransaction, + mutations: &'a [Mutation], + map: UtxoMap, + signer: &'a Signer, +} + +impl<'a> FuzzCase<'a> { + pub(crate) fn new( + scenario: &'a Scenario, + programs: &'a [Program], + transaction: &'a FinalTransaction, + mutations: &'a [Mutation], + signer: &'a Signer, + ) -> Self { + Self { + scenario, + programs, + transaction, + mutations, + map: scenario.utxo_map(mutations), + signer, + } + } + + /// # Panics + /// Panics when no program is named `name`, it is not a `P`, or a binding reads it before it is built. + pub fn program(&self, name: &str) -> FuzzProgram<'_, P> { + let (index, program_scenario) = self.scenario.program_scenario::

(name); + let program = self + .programs + .get(index) + .unwrap_or_else(|| panic!("`{name}` is not built yet, only programs added before a binding can be read")); + + FuzzProgram::new( + program_scenario, + program, + program_scenario.witness_in(self.transaction, &self.map), + &self.map, + self.signer.get_network(), + ) + } + + /// The transaction with the mutations applied and every program placed, before signing. + pub fn transaction(&self) -> &FinalTransaction { + self.transaction + } + + /// What the case changed in the assembled transaction. + pub fn mutations(&self) -> &[Mutation] { + self.mutations + } + + /// Where input `old_index` of the assembled transaction sits after the mutations. + /// + /// # Panics + /// Panics when the assembled transaction has no input `old_index`. + pub fn input_index(&self, old_index: usize) -> usize { + self.map.new_input_position(old_index) + } + + /// Where output `old_index` of the assembled transaction sits after the mutations. + /// + /// # Panics + /// Panics when the assembled transaction has no output `old_index`. + pub fn output_index(&self, old_index: usize) -> usize { + self.map.new_output_position(old_index) + } + + /// # Panics + /// Panics when the transaction has no input `index`. + pub fn input_script_hash(&self, index: usize) -> [u8; 32] { + hash_script( + &self.transaction.inputs()[index] + .partial_input + .witness_utxo + .script_pubkey, + ) + } + + /// The asset of input `index`, explicit or unblinded. + /// + /// # Panics + /// Panics when the transaction has no input `index` or it is confidential without secrets. + pub fn input_asset(&self, index: usize) -> [u8; 32] { + self.input_asset_and_amount(index).0.to_byte_array() + } + + /// The amount of input `index`, explicit or unblinded. + /// + /// # Panics + /// Panics when the transaction has no input `index` or it is confidential without secrets. + pub fn input_amount(&self, index: usize) -> u64 { + self.input_asset_and_amount(index).1 + } + + /// The sequence input `index` is signed with: its own, or the transaction's. + /// + /// # Panics + /// Panics when the transaction has no input `index`. + pub fn input_sequence(&self, index: usize) -> Sequence { + self.transaction.inputs()[index] + .partial_input + .sequence + .unwrap_or(self.transaction.sequence()) + } + + /// # Panics + /// Panics when the transaction has no output `index`. + pub fn output_script_hash(&self, index: usize) -> [u8; 32] { + hash_script(&self.transaction.outputs()[index].script_pubkey) + } + + /// # Panics + /// Panics when the transaction has no output `index`. + pub fn output_asset(&self, index: usize) -> [u8; 32] { + self.transaction.outputs()[index].asset.to_byte_array() + } + + /// # Panics + /// Panics when the transaction has no output `index`. + pub fn output_amount(&self, index: usize) -> u64 { + self.transaction.outputs()[index].amount + } + + /// The lock height as the jets see it: zero when every input sequence is final or the locktime is a time. + pub fn lock_height(&self) -> u32 { + self.read_lock(|locktime| locktime < LOCK_TIME_THRESHOLD) + } + + /// The lock time as the jets see it: zero when every input sequence is final or the locktime is a height. + pub fn lock_time(&self) -> u32 { + self.read_lock(|locktime| locktime >= LOCK_TIME_THRESHOLD) + } + + pub fn network(&self) -> &SimplicityNetwork { + self.signer.get_network() + } + + /// The signer that signs the transaction. + pub fn signer(&self) -> &Signer { + self.signer + } + + /// The name of the program spending input `index`, `None` for a fixed or added input. + pub(crate) fn spending_program(&self, index: usize) -> Option<&'a str> { + self.scenario + .program_scenario_by_input(index, &self.map) + .map(|program_scenario| program_scenario.name()) + } + + /// Every output the lock check protects, with the name of the program it is locked to. + pub(crate) fn locked_outputs(&self) -> Vec<(usize, &'a str)> { + self.scenario.locked_outputs(&self.map) + } + + /// Each program with its bound values, then the mutations. + pub(crate) fn describe(&self) -> String { + let mut description = self.scenario.describe(self.programs, self.transaction, &self.map); + + if !self.mutations.is_empty() { + let mutations = self.mutations.iter().map(ToString::to_string).collect::>(); + description += &format!("; mutations: {}", mutations.join(", ")); + } + + description + } + + fn read_lock(&self, of_kind: impl Fn(u32) -> bool) -> u32 { + let transaction = self + .transaction + .extract_pst() + .expect("fuzz inputs have known assets and amounts") + .0 + .extract_tx() + .expect("the transaction has one kind of locktime"); + let locktime = transaction.lock_time.to_consensus_u32(); + let is_final = transaction.input.iter().all(|input| input.sequence.is_final()); + + if !is_final && of_kind(locktime) { locktime } else { 0 } + } + + fn input_asset_and_amount(&self, index: usize) -> (AssetId, u64) { + self.transaction.inputs()[index] + .partial_input + .asset_and_amount() + .expect("the input is confidential without unblinding secrets") + } +} diff --git a/crates/test/src/fuzz/common.rs b/crates/test/src/fuzz/common.rs new file mode 100644 index 00000000..1baf2ebf --- /dev/null +++ b/crates/test/src/fuzz/common.rs @@ -0,0 +1,22 @@ +use std::fmt::Debug; + +use proptest::strategy::ValueTree; + +/// A generated case that is never shrunk. +pub(crate) struct FixedValueTree(pub(crate) T); + +impl ValueTree for FixedValueTree { + type Value = T; + + fn current(&self) -> T { + self.0.clone() + } + + fn simplify(&mut self) -> bool { + false + } + + fn complicate(&mut self) -> bool { + false + } +} diff --git a/crates/test/src/fuzz/core.rs b/crates/test/src/fuzz/core.rs index 23765416..e199f1a6 100644 --- a/crates/test/src/fuzz/core.rs +++ b/crates/test/src/fuzz/core.rs @@ -1,43 +1,58 @@ +use std::any::Any; +use std::cell::RefCell; use std::fmt::Debug; -use std::marker::PhantomData; +use std::panic::{self, AssertUnwindSafe}; +use std::sync::{Arc, Mutex}; -use proptest::prelude::{BoxedStrategy, TestCaseError}; +use proptest::prelude::TestCaseError; use proptest::strategy::Strategy; -use proptest::test_runner::{Config as ProptestConfig, TestRunner}; +use proptest::test_runner::{Config as ProptestConfig, FailurePersistence, PersistedSeed, TestError, TestRunner}; -use simplicityhl::{Arguments, WitnessValues}; - -use smplx_sdk::program::{ProgramFactory, ProgramSchema, ProgramTrait}; use smplx_sdk::signer::Signer; -use crate::fuzz::FuzzableProgram; -use crate::fuzz::args_strategy::ArgsStrategyBuilder; -use crate::fuzz::fuzz_check::{Expect, FuzzExecutionCheck, ProgramCheck, ProgramExecResult}; -use crate::fuzz::fuzz_transaction::{FuzzTransaction, ProgramTarget}; +use crate::fuzz::case::{Expect, FuzzCase}; +use crate::fuzz::execution::SignedTransaction; +use crate::fuzz::mutations::Mutations; +use crate::fuzz::outcome::{Outcome, SetupError}; +use crate::fuzz::requirement::RequiredValue; +use crate::fuzz::scenario::Scenario; +use crate::fuzz::scenario_strategy::CaseValues; +use crate::fuzz::smart_check::{AuthCheck, LockCheck}; +use crate::fuzz::summary::RunSummary; + +type Spec = dyn Fn(&FuzzCase<'_>) -> Expect; + +/// The smart checks' attacker. Fixed, so a replayed case meets the same attacker. +pub(crate) const ATTACKER_MNEMONIC: &str = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; + +enum CaseFailure { + Setup(SetupError), + Finding(String), +} -pub struct SimplexFuzzEngine { +pub struct SimplexFuzzEngine { pub config: ProptestConfig, pub signer: Signer, - pub strategy: Option>, - pub initial_transaction: Option, - pub program_check: Option>>, - pub _placeholder: PhantomData<(Program, Args, Wit)>, + pub scenario: Scenario, + pub spec: Box, + pub mutations: Option, + pub allow_unspendable: bool, + pub check_lock: bool, + pub check_auth: bool, } -impl SimplexFuzzEngine -where - Program: FuzzableProgram + ProgramFactory + ProgramSchema + Clone + 'static, - Args: Into + Debug + Clone + 'static, - Wit: Into + Debug + Clone + 'static, -{ - pub fn new(config: ProptestConfig, signer: Signer) -> SimplexFuzzEngine { +impl SimplexFuzzEngine { + pub fn new(config: ProptestConfig, signer: Signer, scenario: Scenario) -> SimplexFuzzEngine { SimplexFuzzEngine { config, signer, - strategy: None, - initial_transaction: None, - program_check: None, - _placeholder: Default::default(), + scenario, + spec: Box::new(|_| Expect::Accept), + mutations: None, + allow_unspendable: false, + check_lock: true, + check_auth: true, } } @@ -55,57 +70,109 @@ where self } + /// Sets what should happen to each spend. Without a spec, every spend is expected to be accepted. #[must_use] - pub fn with_custom_strategy( - mut self, - strategy: impl Strategy + 'static, - ) -> Self { - self.strategy = Some(strategy.boxed()); + pub fn spec(mut self, spec: impl Fn(&FuzzCase<'_>) -> Expect + 'static) -> Self { + self.spec = Box::new(spec); self } + /// Mutates the transaction in half the cases. The spec judges the mutated transaction. #[must_use] - pub fn with_custom_transaction(mut self, transaction: FuzzTransaction) -> Self { - self.initial_transaction = Some(transaction); + pub fn mutations(mut self, mutations: Mutations) -> Self { + self.mutations = Some(mutations); self } + /// Lets a run pass without any accepted case, for tests where no spend should ever be accepted. #[must_use] - pub fn with_custom_check(mut self, check: impl ProgramCheck + 'static) -> Self { - self.program_check = Some(Box::new(check)); + pub fn allow_unspendable(mut self) -> Self { + self.allow_unspendable = true; self } - pub fn run(self) { - let mut runner = TestRunner::new(self.config); + /// Skips checking that accepted spends cannot redirect the outputs they lock. + #[must_use] + pub fn without_lock_check(mut self) -> Self { + self.check_lock = false; - let strategy = match self.strategy { - None => ArgsStrategyBuilder::::new().build(), - Some(strategy) => strategy, - }; - let program_check = match self.program_check { - None => Box::new(FuzzExecutionCheck::new( - runner.config().test_name.unwrap_or("simplex_fuzz"), - Expect::Ok, - )), - Some(check) => check, - }; - let initial_transaction = match self.initial_transaction { - None => FuzzTransaction::try_default().expect("Shouldn't fail. Please report a bug"), - Some(tx) => tx, + self + } + + /// Skips checking that accepted spends depend on their signatures. + #[must_use] + pub fn without_auth_check(mut self) -> Self { + self.check_auth = false; + + self + } + + pub fn run(self) { + let strategy = match self + .scenario + .validate() + .and_then(|()| self.scenario.combined_strategy(self.mutations.as_ref())) + { + Ok(strategy) => strategy, + Err(error) => panic!("The fuzz setup is broken, the contract was not tested: {error}"), }; - let result = runner.run(&strategy.no_shrink(), |(arguments, witness)| { - Self::fuzz(&self.signer, &initial_transaction, &*program_check, arguments, witness) + let attacker = Signer::from_mnemonic(ATTACKER_MNEMONIC, *self.signer.get_network()); + assert!( + attacker.get_schnorr_public_key() != self.signer.get_schnorr_public_key(), + "The fuzz setup is broken, the contract was not tested: the signer uses the smart checks attacker mnemonic" + ); + + let setup_error = Arc::new(Mutex::new(None)); + let summary = RefCell::new(RunSummary::new( + self.config.test_name.unwrap_or("simplex_fuzz"), + self.config.rng_seed, + self.scenario.layout(), + self.check_lock, + self.check_auth, + self.mutations.is_some(), + )); + + let mut config = self.config.clone(); + config.failure_persistence = config.failure_persistence.map(|inner| { + Box::new(SkipSetupErrors { + inner, + setup_error: setup_error.clone(), + }) as Box }); + let mut runner = TestRunner::new(config); + + let result = runner.run(&strategy.no_shrink(), |values| { + self.fuzz(&summary, &attacker, values).map_err(|failure| match failure { + CaseFailure::Setup(error) => { + let reason = error.to_string(); + *setup_error.lock().unwrap() = Some(error); + + TestCaseError::fail(reason) + } + CaseFailure::Finding(reason) => TestCaseError::fail(reason), + }) + }); + + let summary = summary.into_inner(); + eprintln!("{summary}"); + + if let Some(error) = setup_error.lock().unwrap().take() { + panic!("The fuzz setup is broken, the contract was not tested: {error}"); + } + match result { - Ok(()) => {} - Err(proptest::test_runner::TestError::Fail(reason, (args, wit))) => { - panic!("Program failed with these arguments: {args} and witness: {wit}, reason: `{reason}`"); + Ok(()) => { + if let Some(reason) = summary.is_run_empty(self.allow_unspendable) { + panic!("{reason}"); + } + } + Err(TestError::Fail(reason, _)) => { + panic!("{reason}"); } Err(err) => { panic!("Fuzzing aborted: {err}"); @@ -113,119 +180,181 @@ where } } - /// Extracted helper that performs exactly one isolated test run. - fn fuzz( - signer: &Signer, - initial_tx: &FuzzTransaction, - program_check: &dyn ProgramCheck, - arguments: Arguments, - witness: WitnessValues, - ) -> Result<(), TestCaseError> { - let (program, script) = Program::build_program(arguments.clone(), signer.get_network()); - - let final_transaction = initial_tx - .prepare_transaction(program.as_ref().as_ref(), &script, &arguments, &witness) - .map_err(|error| TestCaseError::fail(format!("failed to prepare fuzz transaction: {error}")))?; - - // TODO: finalization also checks the witness budget, which fuzzing does not cover yet. - let (pst, signed_witnesses) = signer - .sign_witnesses(&final_transaction) - .map_err(|error| TestCaseError::fail(format!("failed to sign: {error}")))?; - - // Iterate over program inputs to check contract execution - for target in initial_tx.targets().iter().copied() { - let ProgramTarget::Input(input_index) = target else { - continue; - }; - - let signed_witness = signed_witnesses.get(&input_index).unwrap_or(&witness); - let exec_result: ProgramExecResult = - program - .as_ref() - .as_ref() - .execute(&pst, signed_witness, input_index, signer.get_network()); - - if let Err(error) = program_check.call(signer, &pst, &arguments, signed_witness, input_index, exec_result) { - return Err(TestCaseError::fail(format!( - "{error}, args: {arguments}, wit: {signed_witness}" - ))); - } - } + /// Builds one case, asks the spec, runs it and reports what disagrees. + fn fuzz(&self, summary: &RefCell, attacker: &Signer, values: CaseValues) -> Result<(), CaseFailure> { + let mutations = values.change.mutations().to_vec(); + let drew_violation = values.change.violation().is_some(); - Ok(()) - } -} + let (programs, transaction, violated) = + self.scenario.build(values, &self.signer).map_err(CaseFailure::Setup)?; -#[cfg(test)] -mod tests { - use proptest::strategy::Just; - use proptest::test_runner::RngSeed; + if drew_violation && violated.is_none() { + summary.borrow_mut().record_unviolated(); + } + let case = FuzzCase::new(&self.scenario, &programs, &transaction, &mutations, &self.signer); - use smplx_sdk::global::Verbosity; - use smplx_sdk::program::Program; - use smplx_sdk::provider::{EsploraProvider, SimplicityNetwork}; - use smplx_sdk::transaction::{FinalTransaction, PartialInput, RequiredSignature, UTXO}; + let failure = + |reason: String| CaseFailure::Finding(format!("Case failed with {}, reason: `{reason}`", case.describe())); - use crate::config::{DEFAULT_TEST_MNEMONIC, EsploraConfig, FuzzConfig, TestConfig}; - use crate::context::{FuzzMode, TestContext}; + // A generated case that violates a requirement must be rejected, so the spec is not asked. + let expect = match violated { + Some(_) => None, + None => Some(self.ask_spec(&case)?), + }; - use super::*; + let outcomes = SignedTransaction::new(&self.signer, &transaction) + .and_then(|signed| signed.run_all()) + .map_err(CaseFailure::Setup)?; + let rejection = outcomes.iter().find(|(_, outcome)| !outcome.is_accepted()); + let verdict = rejection.map_or(Outcome::Accepted, |(_, outcome)| *outcome); + + summary.borrow_mut().record( + expect, + verdict, + rejection.map(|(index, _)| { + case.spending_program(*index) + .map_or_else(|| format!("input {index}"), ToString::to_string) + }), + ); + summary.borrow_mut().record_mutations(&mutations); - #[derive(Clone)] - struct DummyProgram(Program); + if let Some(finding) = Self::finding(expect, verdict, violated.as_ref()) { + return Err(failure(format!( + "{finding} ({})", + Self::describe_outcomes(&case, &outcomes) + ))); + } - impl AsRef for DummyProgram { - fn as_ref(&self) -> &Program { - &self.0 + if verdict.is_accepted() + && let Some(bypass) = self + .do_smart_checks(summary, attacker, &case) + .map_err(CaseFailure::Setup)? + { + return Err(failure(bypass)); } + + Ok(()) } - impl ProgramFactory for DummyProgram { - fn instantiate_program(args: impl Into) -> Box { - Box::new(DummyProgram(Program::new("fn main() { assert!(true); }", args))) - } + /// A panicking spec is a bug in the test, not a finding against the contract. + fn ask_spec(&self, case: &FuzzCase<'_>) -> Result { + panic::catch_unwind(AssertUnwindSafe(|| (self.spec)(case))).map_err(|_| CaseFailure::Setup(SetupError::Spec)) } - impl ProgramSchema for DummyProgram { - fn argument_types() -> Vec<(simplicityhl::TemplateProgramWitness, simplicityhl::ResolvedType)> { - Vec::new() + /// What the verdict means against the expectation, `None` when they agree. + /// `expect` is `None` for a case that violates a requirement. + fn finding(expect: Option, verdict: Outcome, violated: Option<&RequiredValue<'_>>) -> Option { + match (expect, verdict.is_accepted(), violated) { + (None, true, Some(violated)) => Some(format!( + "unsound: the contract accepted a spend that violates a requirement, {violated}" + )), + (Some(Expect::Reject), true, _) => { + Some("unsound: the contract accepted a spend the spec rejects".to_string()) + } + (Some(Expect::Accept), false, _) => { + Some("liveness: the contract rejected a spend the spec accepts".to_string()) + } + _ => None, } + } + + /// Every program input with its outcome, e.g. "input 0 `vault` accepted, input 1 rejected (assertion)". + fn describe_outcomes(case: &FuzzCase<'_>, outcomes: &[(usize, Outcome)]) -> String { + outcomes + .iter() + .map(|(index, outcome)| match case.spending_program(*index) { + Some(name) => format!("input {index} `{name}` {outcome}"), + None => format!("input {index} {outcome}"), + }) + .collect::>() + .join(", ") + } - fn witness_types() -> Vec<(simplicityhl::TemplateProgramWitness, simplicityhl::ResolvedType)> { - Vec::new() + /// Runs the smart checks on an accepted case and returns the first bypass they find. + fn do_smart_checks( + &self, + summary: &RefCell, + attacker: &Signer, + case: &FuzzCase<'_>, + ) -> Result, SetupError> { + let transaction = case.transaction(); + + if self.check_lock + && let Some(check) = LockCheck::new(&self.signer, attacker, transaction, case.locked_outputs()) + { + let bypasses = check.run()?; + summary.borrow_mut().record_lock_check(); + + if !bypasses.is_empty() { + return Ok(Some(format!("lock bypass: {}", bypasses.join("; ")))); + } } - fn literals() -> Vec { - Vec::new() + if self.check_auth + && let Some(check) = AuthCheck::new(&self.signer, attacker, transaction) + { + let bypasses = check.run()?; + summary.borrow_mut().record_auth_check(); + + if !bypasses.is_empty() { + return Ok(Some(format!("authorization bypass: {}", bypasses.join("; ")))); + } } + + Ok(None) } +} - #[derive(Clone, Debug)] - struct EmptyArgs; +/// Keeps setup errors out of the persisted failures. +#[derive(Clone, Debug)] +struct SkipSetupErrors { + inner: Box, + setup_error: Arc>>, +} - impl From for Arguments { - fn from(_: EmptyArgs) -> Self { - Self::default() - } +impl FailurePersistence for SkipSetupErrors { + fn load_persisted_failures2(&self, source_file: Option<&'static str>) -> Vec { + self.inner.load_persisted_failures2(source_file) } - impl From for WitnessValues { - fn from(_: EmptyArgs) -> Self { - Self::default() + fn save_persisted_failure2( + &mut self, + source_file: Option<&'static str>, + seed: PersistedSeed, + shrunken_value: &dyn Debug, + ) { + if self.setup_error.lock().unwrap().is_none() { + self.inner.save_persisted_failure2(source_file, seed, shrunken_value); } } - fn build_test_engine(context: TestContext) -> SimplexFuzzEngine { - let mut final_tx = FinalTransaction::new(); - final_tx.add_input(PartialInput::new(UTXO::default()), RequiredSignature::None); + fn box_clone(&self) -> Box { + Box::new(self.clone()) + } - let fuzz_tx = FuzzTransaction::new(final_tx, [ProgramTarget::Input(0)]).unwrap(); + fn eq(&self, other: &dyn FailurePersistence) -> bool { + other + .as_any() + .downcast_ref::() + .is_some_and(|other| *self.inner == *other.inner) + } - context - .engine() - .with_custom_strategy(Just((Arguments::default(), WitnessValues::default()))) - .with_custom_transaction(fuzz_tx) + fn as_any(&self) -> &dyn Any { + self } +} + +#[cfg(test)] +mod tests { + use proptest::test_runner::RngSeed; + + use smplx_sdk::global::Verbosity; + use smplx_sdk::provider::{EsploraProvider, SimplicityNetwork}; + + use crate::config::{DEFAULT_TEST_MNEMONIC, EsploraConfig, FuzzConfig, TestConfig}; + use crate::context::TestContext; + + use super::*; #[test] fn context_preserves_fuzz_network_signer_and_config() { @@ -250,7 +379,7 @@ mod tests { .fuzz(test_name, source_file) .unwrap(); - let engine = build_test_engine(context); + let engine = context.engine(Scenario::default()); let network = SimplicityNetwork::Liquid; assert_eq!(*engine.signer.get_network(), network); @@ -277,7 +406,7 @@ mod tests { assert_eq!(*context.get_network(), network); assert_eq!(*context.random_signer().get_network(), network); - let engine = build_test_engine(context); + let engine = context.engine(Scenario::default()); assert_eq!(*engine.signer.get_network(), network); } @@ -310,7 +439,8 @@ mod tests { custom_config.cases = 31; custom_config.rng_seed = RngSeed::Fixed(37); - let engine = build_test_engine(context) + let engine = context + .engine(Scenario::default()) .with_custom_signer(custom_signer) .with_custom_config(custom_config); @@ -329,8 +459,8 @@ mod tests { .fuzz("crate::repeated_engine_fuzz_test", file!()) .unwrap(); - let first = context.engine::(); - let second = context.engine::(); + let first = context.engine(Scenario::default()); + let second = context.engine(Scenario::default()); let default_key = context.get_default_signer().get_schnorr_public_key(); diff --git a/crates/test/src/fuzz/execution.rs b/crates/test/src/fuzz/execution.rs new file mode 100644 index 00000000..cc4adeea --- /dev/null +++ b/crates/test/src/fuzz/execution.rs @@ -0,0 +1,84 @@ +use std::collections::HashMap; + +use simplicityhl::WitnessValues; +use simplicityhl::elements::pset::PartiallySignedTransaction; + +use smplx_sdk::provider::SimplicityNetwork; +use smplx_sdk::signer::Signer; +use smplx_sdk::transaction::FinalTransaction; + +use crate::fuzz::outcome::{Outcome, SetupError}; + +/// A transaction signed by one signer, ready to run its program inputs. +pub(crate) struct SignedTransaction<'a> { + transaction: &'a FinalTransaction, + network: &'a SimplicityNetwork, + pst: PartiallySignedTransaction, + witnesses: HashMap, +} + +impl<'a> SignedTransaction<'a> { + /// # Errors + /// Returns [`SetupError::Signing`] when `signer` cannot sign `transaction`. + pub(crate) fn new(signer: &'a Signer, transaction: &'a FinalTransaction) -> Result { + let (pst, witnesses) = signer + .sign_witnesses(transaction) + .map_err(|error| SetupError::Signing(error.to_string()))?; + + Ok(Self { + transaction, + network: signer.get_network(), + pst, + witnesses, + }) + } + + /// Runs the program of input `index`. + /// + /// # Errors + /// Returns a [`SetupError`] when the failure comes from the fuzz setup rather than the program. + /// + /// # Panics + /// Panics when input `index` runs no program. + pub(crate) fn run(&self, index: usize) -> Result { + let program = &self.transaction.inputs()[index] + .program_input + .as_ref() + .expect("the input runs a program") + .program; + + Outcome::classify(program.finalize(&self.pst, &self.witnesses[&index], index, self.network)) + } + + /// Runs every program input, in order. + /// + /// # Errors + /// Returns a [`SetupError`] when a failure comes from the fuzz setup rather than a program. + pub(crate) fn run_all(&self) -> Result, SetupError> { + self.program_inputs() + .map(|index| Ok((index, self.run(index)?))) + .collect() + } + + /// Whether every program input accepts, stopping at the first that rejects. + /// + /// # Errors + /// Returns a [`SetupError`] when a failure comes from the fuzz setup rather than a program. + pub(crate) fn accepts_all(&self) -> Result { + for index in self.program_inputs() { + if !self.run(index)?.is_accepted() { + return Ok(false); + } + } + + Ok(true) + } + + fn program_inputs(&self) -> impl Iterator + '_ { + self.transaction + .inputs() + .iter() + .enumerate() + .filter_map(|(index, input)| input.program_input.as_ref().map(|_| index)) + } +} diff --git a/crates/test/src/fuzz/fuzz_check.rs b/crates/test/src/fuzz/fuzz_check.rs deleted file mode 100644 index ceb3aa1a..00000000 --- a/crates/test/src/fuzz/fuzz_check.rs +++ /dev/null @@ -1,84 +0,0 @@ -use std::sync::Arc; - -use simplicityhl::elements::pset::PartiallySignedTransaction; -use simplicityhl::simplicity::bit_machine::ExecutionError; -use simplicityhl::simplicity::{RedeemNode, Value}; -use simplicityhl::{Arguments, WitnessValues}; - -use smplx_sdk::program::ProgramError; -use smplx_sdk::signer::Signer; - -pub type ProgramExecResult = Result<(Arc, Value), ProgramError>; - -pub trait ProgramCheck { - fn call( - &self, - signer: &Signer, - tx: &PartiallySignedTransaction, - arguments: &Arguments, - witness: &WitnessValues, - input_index: usize, - program_exec_result: ProgramExecResult, - ) -> Result<(), String>; -} - -#[derive(Clone, Copy)] -pub enum Expect { - /// Program execution succeeds. - Ok, - /// A jet fails during execution, for example because a contract `assert!` fails. - AssertFailed, - /// Execution reaches a pruned branch, for example through `unwrap(None)` or a `safe_*` overflow. - PrunedBranch, -} - -/// Checks that each fuzzed program execution matches the expected outcome. -/// -/// Used by `run` by default. For custom checks, implement [`ProgramCheck`] -/// and pass your check to `with_custom_check`. -pub struct FuzzExecutionCheck { - test_name: &'static str, - expect: Expect, -} - -impl FuzzExecutionCheck { - pub const fn new(test_name: &'static str, expect: Expect) -> Self { - Self { test_name, expect } - } - - fn expected_outcome(expect: Expect) -> &'static str { - match expect { - Expect::Ok => "execution succeeded", - Expect::AssertFailed => "assertion failure (`assert!`)", - Expect::PrunedBranch => "execution reached a pruned branch", - } - } -} - -impl ProgramCheck for FuzzExecutionCheck { - fn call( - &self, - _signer: &Signer, - _transaction: &PartiallySignedTransaction, - _arguments: &Arguments, - _witness: &WitnessValues, - _input_index: usize, - program_exec_result: ProgramExecResult, - ) -> Result<(), String> { - match (self.expect, &program_exec_result) { - (Expect::Ok, Ok(_)) => Ok(()), - (Expect::AssertFailed, Err(ProgramError::Pruning(ExecutionError::JetFailed(_)))) => Ok(()), - (Expect::PrunedBranch, Err(ProgramError::Pruning(ExecutionError::ReachedPrunedBranch(_)))) => Ok(()), - (expect, Ok(_)) => Err(format!( - "Fuzz test `{}`: expected {}, but execution succeeded", - self.test_name, - Self::expected_outcome(expect), - )), - (expect, Err(error)) => Err(format!( - "Fuzz test `{}`: expected {}, but execution failed: {error}", - self.test_name, - Self::expected_outcome(expect), - )), - } - } -} diff --git a/crates/test/src/fuzz/fuzz_program.rs b/crates/test/src/fuzz/fuzz_program.rs deleted file mode 100644 index 0d94908b..00000000 --- a/crates/test/src/fuzz/fuzz_program.rs +++ /dev/null @@ -1,18 +0,0 @@ -use simplicityhl::Arguments; -use simplicityhl::elements::Script; - -use smplx_sdk::program::{Program, ProgramFactory}; -use smplx_sdk::provider::SimplicityNetwork; - -pub trait FuzzableProgram>: AsRef { - fn build_program(args: impl Into, network: &SimplicityNetwork) -> (Box

, Script); -} - -impl + ProgramFactory

> FuzzableProgram

for P { - fn build_program(args: impl Into, network: &SimplicityNetwork) -> (Box

, Script) { - let prog = P::instantiate_program(args); - let script = prog.as_ref().as_ref().get_script_pubkey(network); - - (prog, script) - } -} diff --git a/crates/test/src/fuzz/fuzz_transaction.rs b/crates/test/src/fuzz/fuzz_transaction.rs deleted file mode 100644 index e2b64082..00000000 --- a/crates/test/src/fuzz/fuzz_transaction.rs +++ /dev/null @@ -1,303 +0,0 @@ -use std::collections::HashSet; - -use simplicityhl::elements::Script; -use simplicityhl::{Arguments, WitnessValues}; - -use smplx_sdk::program::Program; -use smplx_sdk::transaction::{FinalTransaction, PartialInput, ProgramInput, RequiredSignature, UTXO}; - -use crate::error::FuzzError; - -type PostHook = dyn Fn(&mut FinalTransaction, &Script, &Arguments, &WitnessValues) -> Result<(), FuzzError>; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum ProgramTarget { - Input(usize), - Output(usize), -} - -/// Object, which builds an initial transaction in a fuzzing step to make it valid after -/// generating of script hashes after program compiling. -pub struct FuzzTransaction { - initial_tx: FinalTransaction, - targets: Vec, - post_hook: Option>, -} - -impl FuzzTransaction { - /// Creates a reusable fuzz transaction. - /// - /// # Errors - /// Returns an error when there are no input targets, a target is duplicated, or a target index is out of bounds. - pub fn new( - initial_tx: FinalTransaction, - targets: impl IntoIterator, - ) -> Result { - let targets = targets.into_iter().collect::>(); - - Self::validate_targets(&initial_tx, &targets)?; - - Ok(Self { - initial_tx, - targets, - post_hook: None, - }) - } - - /// Creates a fuzz transaction with a default UTXO at input 0 as its only program target. - /// - /// No signature is requested, and no post hook is configured. Use [`Self::new`] - /// for a custom transaction or [`FuzzTransaction::with_post_hook`] to modify each fuzz case. - pub fn try_default() -> Result { - const PROGRAM_TARGET: ProgramTarget = ProgramTarget::Input(0); - - let mut transaction = FinalTransaction::new(); - - transaction.add_input(PartialInput::new(UTXO::default()), RequiredSignature::None); - - FuzzTransaction::new(transaction, [PROGRAM_TARGET]) - } - - #[must_use] - pub fn with_post_hook( - mut self, - hook: impl Fn(&mut FinalTransaction, &Script, &Arguments, &WitnessValues) -> Result<(), FuzzError> + 'static, - ) -> Self { - self.post_hook = Some(Box::new(hook)); - self - } - - #[must_use] - pub fn targets(&self) -> &[ProgramTarget] { - &self.targets - } - - /// Replaces the scriptPubKey at a selected input or output. - /// - /// # Errors - /// Returns an error when the selected target index is out of bounds. - pub fn set_program_script( - tx: &mut FinalTransaction, - target: ProgramTarget, - script: &Script, - ) -> Result<(), FuzzError> { - match target { - ProgramTarget::Input(index) => { - let input_count = tx.n_inputs(); - let input = tx - .inputs_mut() - .get_mut(index) - .ok_or(FuzzError::InputTargetOutOfBounds { index, input_count })?; - - input.partial_input.witness_utxo.script_pubkey = script.clone(); - } - ProgramTarget::Output(index) => { - let output_count = tx.n_outputs(); - let output = tx - .outputs_mut() - .get_mut(index) - .ok_or(FuzzError::OutputTargetOutOfBounds { index, output_count })?; - - output.script_pubkey = script.clone(); - } - } - - Ok(()) - } - - pub(crate) fn prepare_transaction( - &self, - program: &Program, - script: &Script, - arguments: &Arguments, - witness: &WitnessValues, - ) -> Result { - let mut tx = self.initial_tx.clone(); - - for target in self.targets.iter().copied() { - Self::set_program_script(&mut tx, target, script)?; - - if let ProgramTarget::Input(index) = target { - let input_count = tx.n_inputs(); - let input = tx - .inputs_mut() - .get_mut(index) - .ok_or(FuzzError::InputTargetOutOfBounds { index, input_count })?; - - input.program_input = Some(ProgramInput::new(Box::new(program.clone()), witness.clone())); - } - } - - if let Some(post_hook) = &self.post_hook { - post_hook(&mut tx, script, arguments, witness)?; - } - - Ok(tx) - } - - fn validate_targets(initial_tx: &FinalTransaction, targets: &[ProgramTarget]) -> Result<(), FuzzError> { - if targets.is_empty() { - return Err(FuzzError::NoProgramTargets); - } - - let mut unique_targets = HashSet::with_capacity(targets.len()); - let mut has_input_target = false; - - for target in targets.iter().copied() { - if !unique_targets.insert(target) { - return Err(FuzzError::DuplicateProgramTarget(target)); - } - - match target { - ProgramTarget::Input(index) => { - if index >= initial_tx.n_inputs() { - return Err(FuzzError::InputTargetOutOfBounds { - index, - input_count: initial_tx.n_inputs(), - }); - } - - has_input_target = true; - } - ProgramTarget::Output(index) if index >= initial_tx.n_outputs() => { - return Err(FuzzError::OutputTargetOutOfBounds { - index, - output_count: initial_tx.n_outputs(), - }); - } - ProgramTarget::Output(_) => {} - } - } - - if !has_input_target { - return Err(FuzzError::NoInputTargets); - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use std::cell::Cell; - use std::rc::Rc; - - use simplicityhl::elements::{OutPoint, Script, TxOut, Txid}; - - use smplx_sdk::provider::SimplicityNetwork; - use smplx_sdk::transaction::{PartialInput, PartialOutput, RequiredSignature, UTXO}; - - use super::*; - - const DUMMY_PROGRAM: &str = r" - fn main() { - assert!(true); - } - "; - - fn initial_transaction() -> FinalTransaction { - let network = SimplicityNetwork::default_regtest(); - let mut tx = FinalTransaction::new(); - - for vout in 0..2 { - let utxo = UTXO { - outpoint: OutPoint::new(Txid::from_byte_array(Default::default()), vout), - txout: TxOut::new_fee(1_000, network.policy_asset()), - secrets: None, - }; - tx.add_input(PartialInput::new(utxo), RequiredSignature::None); - tx.add_output(PartialOutput::new(Script::new(), 500, network.policy_asset())); - } - - tx - } - - fn prepared_program() -> (Program, Script) { - let program = Program::new(DUMMY_PROGRAM, Arguments::default()); - let script = program.get_script_pubkey(&SimplicityNetwork::default_regtest()); - (program, script) - } - - #[test] - fn rejects_empty_duplicate_and_out_of_bounds_targets() { - assert_eq!( - FuzzTransaction::new(initial_transaction(), []).err(), - Some(FuzzError::NoProgramTargets) - ); - assert_eq!( - FuzzTransaction::new(initial_transaction(), [ProgramTarget::Output(0)]).err(), - Some(FuzzError::NoInputTargets) - ); - assert_eq!( - FuzzTransaction::new( - initial_transaction(), - [ProgramTarget::Input(0), ProgramTarget::Input(0)], - ) - .err(), - Some(FuzzError::DuplicateProgramTarget(ProgramTarget::Input(0))) - ); - assert_eq!( - FuzzTransaction::new(initial_transaction(), [ProgramTarget::Output(2)]).err(), - Some(FuzzError::OutputTargetOutOfBounds { - index: 2, - output_count: 2, - }) - ); - } - - #[test] - fn injects_program_into_all_targets_without_consuming_them() { - let builder = FuzzTransaction::new( - initial_transaction(), - [ProgramTarget::Input(0), ProgramTarget::Output(1)], - ) - .unwrap(); - let (program, script) = prepared_program(); - let arguments = Arguments::default(); - let witness = WitnessValues::default(); - - let first = builder - .prepare_transaction(&program, &script, &arguments, &witness) - .unwrap(); - let second = builder - .prepare_transaction(&program, &script, &arguments, &witness) - .unwrap(); - - assert_eq!(builder.targets().len(), 2); - assert!(first.inputs()[0].program_input.is_some()); - assert_eq!(first.inputs()[0].partial_input.witness_utxo.script_pubkey, script); - assert_eq!(first.outputs()[1].script_pubkey, script); - assert!(second.inputs()[0].program_input.is_some()); - assert_eq!(second.outputs()[1].script_pubkey, script); - } - - #[test] - fn runs_post_hook_after_program_injection_and_propagates_errors() { - let hook_ran = Rc::new(Cell::new(false)); - let hook_ran_in_callback = Rc::clone(&hook_ran); - let builder = FuzzTransaction::new(initial_transaction(), [ProgramTarget::Input(0)]) - .unwrap() - .with_post_hook(move |tx, script, _, _| { - assert_eq!(tx.inputs()[0].partial_input.witness_utxo.script_pubkey, *script); - hook_ran_in_callback.set(true); - FuzzTransaction::set_program_script(tx, ProgramTarget::Input(0), &Script::new()) - }); - let (program, script) = prepared_program(); - - let prepared = builder - .prepare_transaction(&program, &script, &Arguments::default(), &WitnessValues::default()) - .unwrap(); - - assert!(hook_ran.get()); - assert!(prepared.inputs()[0].partial_input.witness_utxo.script_pubkey.is_empty()); - - let failing_builder = FuzzTransaction::new(initial_transaction(), [ProgramTarget::Input(0)]) - .unwrap() - .with_post_hook(|_, _, _, _| Err(FuzzError::PostHook("hook failed".to_string()))); - let error = failing_builder - .prepare_transaction(&program, &script, &Arguments::default(), &WitnessValues::default()) - .unwrap_err(); - - assert_eq!(error, FuzzError::PostHook("hook failed".to_string())); - } -} diff --git a/crates/test/src/fuzz/mod.rs b/crates/test/src/fuzz/mod.rs index 750012cf..038e1efe 100644 --- a/crates/test/src/fuzz/mod.rs +++ b/crates/test/src/fuzz/mod.rs @@ -1,14 +1,27 @@ -pub mod args_strategy; -pub mod core; -pub mod fuzz_check; -pub mod fuzz_program; -pub mod fuzz_transaction; -pub mod utils; +mod case; +mod common; +mod core; +mod execution; +mod mutations; +mod outcome; +mod program; +mod program_scenario; +mod program_strategy; +mod requirement; +mod scenario; +mod scenario_strategy; +mod smart_check; +mod summary; +mod utils; +mod witness_path; pub use proptest; -pub use crate::error::FuzzError; +pub use case::{Expect, FuzzCase}; pub use core::SimplexFuzzEngine; -pub use fuzz_check::{ProgramCheck, ProgramExecResult}; -pub use fuzz_program::FuzzableProgram; +pub use mutations::{Mutation, Mutations}; +pub use program::{FuzzProgram, FuzzProgramTrait}; +pub use program_scenario::FuzzProgramScenarioBuilder; +pub use program_strategy::FuzzProgramValues; +pub use scenario::Scenario; pub use utils::{generate_random_value_by_ty, random_arguments, random_witness}; diff --git a/crates/test/src/fuzz/mutations/core.rs b/crates/test/src/fuzz/mutations/core.rs new file mode 100644 index 00000000..f6195f05 --- /dev/null +++ b/crates/test/src/fuzz/mutations/core.rs @@ -0,0 +1,69 @@ +/// Which mutations a run draws. +#[derive(Clone, Debug)] +pub struct Mutations { + pub(crate) amounts: bool, + pub(crate) assets: bool, + pub(crate) timelocks: bool, + pub(crate) layout: bool, + pub(crate) everywhere: bool, +} + +impl Default for Mutations { + fn default() -> Self { + Self { + amounts: true, + assets: true, + timelocks: true, + layout: false, + everywhere: false, + } + } +} + +impl Mutations { + /// No kind, to enable them one by one. + pub fn none() -> Self { + Self { + amounts: false, + assets: false, + timelocks: false, + layout: false, + everywhere: false, + } + } + + /// Transfers value between outputs of the same asset. + pub fn amounts(mut self) -> Self { + self.amounts = true; + + self + } + + /// Pays outputs in other assets. + pub fn assets(mut self) -> Self { + self.assets = true; + + self + } + + /// Sets the locktime and input sequences. + pub fn timelocks(mut self) -> Self { + self.timelocks = true; + + self + } + + /// Adds attacker UTXOs and reorders existing UTXOs. + pub fn layout(mut self) -> Self { + self.layout = true; + + self + } + + /// Mutates every UTXO, not only programs that "spend" or "lock". + pub fn everywhere(mut self) -> Self { + self.everywhere = true; + + self + } +} diff --git a/crates/test/src/fuzz/mutations/mod.rs b/crates/test/src/fuzz/mutations/mod.rs new file mode 100644 index 00000000..8c9dadf2 --- /dev/null +++ b/crates/test/src/fuzz/mutations/mod.rs @@ -0,0 +1,9 @@ +mod core; +mod mutation; +mod mutator; +mod utxo_map; + +pub use core::Mutations; +pub use mutation::Mutation; +pub(crate) use mutator::Mutator; +pub(crate) use utxo_map::UtxoMap; diff --git a/crates/test/src/fuzz/mutations/mutation.rs b/crates/test/src/fuzz/mutations/mutation.rs new file mode 100644 index 00000000..c8e68509 --- /dev/null +++ b/crates/test/src/fuzz/mutations/mutation.rs @@ -0,0 +1,166 @@ +use std::fmt::{Display, Formatter, Result as FmtResult}; + +use simplicityhl::elements::{AssetId, LockTime, OutPoint, Script, Sequence, Txid}; + +use smplx_sdk::transaction::{FinalTransaction, PartialInput, PartialOutput, UTXO}; + +use crate::fuzz::outcome::SetupError; + +/// One change to the assembled transaction. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Mutation { + /// Transfers `amount` from output `from` to output `to`, which hold the same asset. + TransferValue { from: usize, to: usize, amount: u64 }, + /// Pays output `output` in `asset`. An attacker input of `asset` and an attacker output + /// taking the leftover amount are appended, so the transaction stays balanced. + SwapAsset { output: usize, asset: AssetId }, + /// Sets the locktime of the transaction and of every input. + SetLocktime(LockTime), + /// Sets the sequence of input `input`. + SetSequence { input: usize, sequence: Sequence }, + /// Appends an attacker output with `amount` taken from output `from`. + AddOutput { from: usize, amount: u64 }, + /// Appends an attacker input of `amount` of `asset` and an attacker output paying it out. + AddInput { asset: AssetId, amount: u64 }, + /// Swaps two inputs. + SwapInputs(usize, usize), + /// Swaps two outputs. + SwapOutputs(usize, usize), +} + +impl Mutation { + /// # Errors + /// Returns a [`SetupError`] when the mutation does not fit `transaction`. + pub(crate) fn apply(&self, transaction: &mut FinalTransaction) -> Result<(), SetupError> { + let unfit = || SetupError::Engine(format!("`{self}` does not fit the transaction")); + + match *self { + Self::TransferValue { from, to, amount } => { + let outputs = transaction.outputs_mut(); + + if from == to + || from >= outputs.len() + || to >= outputs.len() + || outputs[from].asset != outputs[to].asset + { + return Err(unfit()); + } + + outputs[from].amount = outputs[from].amount.checked_sub(amount).ok_or_else(unfit)?; + outputs[to].amount = outputs[to].amount.checked_add(amount).ok_or_else(unfit)?; + } + Self::SwapAsset { output, asset } => { + let swapped = transaction.outputs().get(output).ok_or_else(unfit)?; + let (amount, freed) = (swapped.amount, swapped.asset); + + if freed == asset { + return Err(unfit()); + } + + transaction.outputs_mut()[output].asset = asset; + + Self::add_attacker_utxos(transaction, asset, freed, amount); + } + Self::SetLocktime(locktime) => { + for input in transaction.inputs_mut() { + input.partial_input.locktime = Some(locktime); + } + + transaction.set_locktime(locktime); + } + Self::SetSequence { input, sequence } => { + transaction + .inputs_mut() + .get_mut(input) + .ok_or_else(unfit)? + .partial_input + .sequence = Some(sequence); + } + Self::AddOutput { from, amount } => { + let source = transaction.outputs_mut().get_mut(from).ok_or_else(unfit)?; + source.amount = source.amount.checked_sub(amount).ok_or_else(unfit)?; + let asset = source.asset; + + transaction.add_output(PartialOutput::new(Self::attacker_script(), amount, asset)); + } + Self::AddInput { asset, amount } => { + Self::add_attacker_utxos(transaction, asset, asset, amount); + } + Self::SwapInputs(first, second) => { + if first == second || first.max(second) >= transaction.n_inputs() { + return Err(unfit()); + } + + transaction.inputs_mut().swap(first, second); + } + Self::SwapOutputs(first, second) => { + if first == second || first.max(second) >= transaction.n_outputs() { + return Err(unfit()); + } + + transaction.outputs_mut().swap(first, second); + } + } + + Ok(()) + } + + pub(crate) fn kind(&self) -> &'static str { + match self { + Self::TransferValue { .. } => "transfer value", + Self::SwapAsset { .. } => "swap asset", + Self::SetLocktime(_) => "set locktime", + Self::SetSequence { .. } => "set sequence", + Self::AddOutput { .. } => "add output", + Self::AddInput { .. } => "add input", + Self::SwapInputs(..) => "swap inputs", + Self::SwapOutputs(..) => "swap outputs", + } + } + + /// Appends an attacker input of `amount` of `input_asset` and an attacker output of `amount` of `output_asset`. + fn add_attacker_utxos( + transaction: &mut FinalTransaction, + input_asset: AssetId, + output_asset: AssetId, + amount: u64, + ) { + let utxo = UTXO::explicit( + OutPoint::new(Txid::from_byte_array([0xa7; 32]), transaction.n_inputs() as u32), + Script::new(), + amount, + input_asset, + ); + + transaction.add_input(PartialInput::new(utxo), None); + transaction.add_output(PartialOutput::new(Self::attacker_script(), amount, output_asset)); + } + + /// `OP_TRUE`, a script anyone can spend. + fn attacker_script() -> Script { + Script::from(vec![0x51]) + } +} + +impl Display for Mutation { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + Self::TransferValue { from, to, amount } => { + write!(f, "transfer {amount} from output {from} to output {to}") + } + Self::SwapAsset { output, asset } => write!(f, "pay output {output} in asset {asset}"), + Self::SetLocktime(locktime) => write!(f, "set the locktime to {}", locktime.to_consensus_u32()), + Self::SetSequence { input, sequence } => { + write!( + f, + "set the sequence of input {input} to {:#x}", + sequence.to_consensus_u32() + ) + } + Self::AddOutput { from, amount } => write!(f, "add an output of {amount} from output {from}"), + Self::AddInput { asset, amount } => write!(f, "add an input of {amount} in asset {asset}"), + Self::SwapInputs(first, second) => write!(f, "swap inputs {first} and {second}"), + Self::SwapOutputs(first, second) => write!(f, "swap outputs {first} and {second}"), + } + } +} diff --git a/crates/test/src/fuzz/mutations/mutator.rs b/crates/test/src/fuzz/mutations/mutator.rs new file mode 100644 index 00000000..56f9c025 --- /dev/null +++ b/crates/test/src/fuzz/mutations/mutator.rs @@ -0,0 +1,456 @@ +use proptest::test_runner::TestRng; + +use rand::RngExt; + +use simplicityhl::elements::{AssetId, LockTime, Sequence}; + +use smplx_sdk::transaction::FinalTransaction; + +use crate::fuzz::mutations::{Mutation, Mutations, UtxoMap}; +use crate::fuzz::outcome::SetupError; +use crate::fuzz::program_scenario::FuzzProgramScenario; +use crate::fuzz::program_strategy::Word; + +const MUTATIONS_MAX: usize = 3; +const BOUNDARY_PERCENT: u32 = 50; +const LITERAL_PERCENT: u32 = 20; +const FRESH_ASSET_PERCENT: u32 = 50; + +type Draw = fn(&Mutator, &FinalTransaction, &UtxoMap, &mut TestRng) -> Option; + +/// Draws the mutations of a case. +#[derive(Clone, Debug)] +pub(crate) struct Mutator { + mutations: Mutations, + transaction: FinalTransaction, + /// Outputs mutated by default: those programs "lock" and the fee outputs. + target_outputs: Vec, + /// Inputs mutated by default: those programs "spend". + target_inputs: Vec, + /// Program literals that fit a `u64`. + literals: Vec, + /// The template's locktime. + locktime: u32, +} + +impl Mutator { + /// # Errors + /// Returns a [`SetupError`] when `transaction` is not a balanced transaction of explicit or unblinded UTXOs, + /// can't be extracted, or no enabled mutation is applied. + pub(crate) fn new( + mutations: Mutations, + transaction: &FinalTransaction, + program_scenarios: &[FuzzProgramScenario], + ) -> Result { + // An input of unknown amount fails the balance check too + if transaction.is_balanced() != Ok(true) { + return Err(SetupError::Transaction( + "mutations need a balanced transaction of explicit or unblinded UTXOs".to_string(), + )); + } + + let mutator = Self { + mutations, + transaction: transaction.clone(), + target_outputs: Self::find_target_outputs(transaction, program_scenarios), + target_inputs: Self::find_target_inputs(transaction, program_scenarios), + literals: Self::literals(program_scenarios), + locktime: Self::locktime(transaction)?, + }; + + if !mutator.applies(transaction) { + return Err(SetupError::Transaction( + "no mutation applies to the transaction, e.g. transferring value needs two outputs of the same asset" + .to_string(), + )); + } + + Ok(mutator) + } + + /// Draws one to `MUTATIONS_MAX` mutations and the transaction they make. + pub(crate) fn generate(&self, rng: &mut TestRng) -> (Vec, FinalTransaction) { + let mut transaction = self.transaction.clone(); + let mut map = UtxoMap::new(&transaction, &[]); + let mut mutations = Vec::new(); + + for _ in 0..rng.random_range(1..=MUTATIONS_MAX) { + let Some(mutation) = self.draw(&transaction, &map, rng) else { + break; + }; + + mutation + .apply(&mut transaction) + .expect("a drawn mutation fits the transaction it was drawn from"); + map.follow(&mutation); + mutations.push(mutation); + } + + (mutations, transaction) + } + + /// Whether an enabled kind applies to `transaction`. Timelocks and layout always do. + fn applies(&self, transaction: &FinalTransaction) -> bool { + let map = UtxoMap::new(transaction, &[]); + + self.mutations.timelocks + || self.mutations.layout + || (self.mutations.amounts && !self.transfers(transaction, &map).is_empty()) + || (self.mutations.assets && !self.funded_outputs(transaction, &map).is_empty()) + } + + /// A mutation of a random enabled kind that applies to `transaction`. + fn draw(&self, transaction: &FinalTransaction, map: &UtxoMap, rng: &mut TestRng) -> Option { + let mut draws = self.draws(); + + while !draws.is_empty() { + let specific_draw = draws.swap_remove(rng.random_range(0..draws.len())); + + if let Some(mutation) = specific_draw(self, transaction, map, rng) { + return Some(mutation); + } + } + + None + } + + /// The draws of the enabled kinds. + fn draws(&self) -> Vec { + let mut draws: Vec = Vec::new(); + + if self.mutations.amounts { + draws.push(Self::draw_transfer); + } + + if self.mutations.assets { + draws.push(Self::draw_swap); + } + + if self.mutations.timelocks { + draws.push(Self::draw_locktime); + draws.push(Self::draw_sequence); + } + + if self.mutations.layout { + draws.push(Self::draw_add_output); + draws.push(Self::draw_add_input); + draws.push(Self::draw_swap_inputs); + draws.push(Self::draw_swap_outputs); + } + + draws + } + + fn draw_transfer(&self, transaction: &FinalTransaction, map: &UtxoMap, rng: &mut TestRng) -> Option { + let transfers = self.transfers(transaction, map); + + if transfers.is_empty() { + return None; + } + + let (from, to) = transfers[rng.random_range(0..transfers.len())]; + let amount = self.draw_amount(transaction.outputs()[from].amount, rng); + + Some(Mutation::TransferValue { from, to, amount }) + } + + fn draw_swap(&self, transaction: &FinalTransaction, map: &UtxoMap, rng: &mut TestRng) -> Option { + let swaps = self.funded_outputs(transaction, map); + + if swaps.is_empty() { + return None; + } + + let output = swaps[rng.random_range(0..swaps.len())]; + let asset = Self::draw_asset(transaction, Some(transaction.outputs()[output].asset), rng); + + Some(Mutation::SwapAsset { output, asset }) + } + + /// The template's locktime or a program literal, or +- 1. + fn draw_locktime(&self, _: &FinalTransaction, _: &UtxoMap, rng: &mut TestRng) -> Option { + let bases: Vec = std::iter::once(self.locktime) + .chain(self.literals.iter().filter_map(|literal| u32::try_from(*literal).ok())) + .collect(); + let base = bases[rng.random_range(0..bases.len())]; + + let locktime = match rng.random_range(0..4) { + 0 => 0, + 1 => base.saturating_sub(1), + 2 => base.saturating_add(1), + _ => base, + }; + + Some(Mutation::SetLocktime(LockTime::from_consensus(locktime))) + } + + /// A final or locktime-enabling sequence, or a relative height from a program literal, or +- 1. + fn draw_sequence(&self, transaction: &FinalTransaction, map: &UtxoMap, rng: &mut TestRng) -> Option { + let inputs: Vec = (0..transaction.n_inputs()) + .filter(|position| self.targets_input(*position, map)) + .collect(); + + if inputs.is_empty() { + return None; + } + + let input = inputs[rng.random_range(0..inputs.len())]; + let mut sequences = vec![ + Sequence::MAX, + Sequence::ENABLE_LOCKTIME_NO_RBF, + Sequence::ENABLE_RBF_NO_LOCKTIME, + Sequence::ZERO, + ]; + + for literal in self.literals.iter().filter_map(|literal| u16::try_from(*literal).ok()) { + sequences + .extend([literal.saturating_sub(1), literal, literal.saturating_add(1)].map(Sequence::from_height)); + } + + let sequence = sequences[rng.random_range(0..sequences.len())]; + + Some(Mutation::SetSequence { input, sequence }) + } + + /// Takes part of a targeted output for an attacker output. + fn draw_add_output(&self, transaction: &FinalTransaction, map: &UtxoMap, rng: &mut TestRng) -> Option { + let sources = self.funded_outputs(transaction, map); + + if sources.is_empty() { + return None; + } + + let from = sources[rng.random_range(0..sources.len())]; + let amount = self.draw_amount(transaction.outputs()[from].amount, rng); + + Some(Mutation::AddOutput { from, amount }) + } + + /// An attacker UTXO in an asset of the transaction or a fresh one, worth up to the largest output. + fn draw_add_input(&self, transaction: &FinalTransaction, _: &UtxoMap, rng: &mut TestRng) -> Option { + let largest = transaction + .outputs() + .iter() + .map(|output| output.amount) + .max() + .unwrap_or(0); + + Some(Mutation::AddInput { + asset: Self::draw_asset(transaction, None, rng), + amount: self.draw_amount(largest.max(1), rng), + }) + } + + fn draw_swap_inputs(&self, transaction: &FinalTransaction, map: &UtxoMap, rng: &mut TestRng) -> Option { + let (first, second) = Self::draw_pair( + transaction.n_inputs(), + |position| self.targets_input(position, map), + rng, + )?; + + Some(Mutation::SwapInputs(first, second)) + } + + fn draw_swap_outputs(&self, transaction: &FinalTransaction, map: &UtxoMap, rng: &mut TestRng) -> Option { + let (first, second) = Self::draw_pair( + transaction.n_outputs(), + |position| self.targets_output(position, map), + rng, + )?; + + Some(Mutation::SwapOutputs(first, second)) + } + + /// Two of `count` positions, at least one of them targeted. + fn draw_pair(count: usize, targeted: impl Fn(usize) -> bool, rng: &mut TestRng) -> Option<(usize, usize)> { + let pairs: Vec<(usize, usize)> = (0..count) + .flat_map(|first| (first + 1..count).map(move |second| (first, second))) + .filter(|&(first, second)| targeted(first) || targeted(second)) + .collect(); + + (!pairs.is_empty()).then(|| pairs[rng.random_range(0..pairs.len())]) + } + + /// An asset of the transaction other than `except`, or a fresh one. + fn draw_asset(transaction: &FinalTransaction, except: Option, rng: &mut TestRng) -> AssetId { + let others: Vec = Self::assets(transaction) + .into_iter() + .filter(|asset| Some(*asset) != except) + .collect(); + + if others.is_empty() || rng.random_range(0..100) < FRESH_ASSET_PERCENT { + AssetId::from_byte_array(rng.random()) + } else { + others[rng.random_range(0..others.len())] + } + } + + /// Boundaries (1, all, all but 1), program literals, or a random part of `available`. + fn draw_amount(&self, available: u64, rng: &mut TestRng) -> u64 { + let roll = rng.random_range(0..100); + let literals: Vec = self + .literals + .iter() + .copied() + .filter(|amount| (1..=available).contains(amount)) + .collect(); + + if roll < BOUNDARY_PERCENT { + let boundaries: Vec = [1, available, available - 1] + .into_iter() + .filter(|amount| *amount > 0) + .collect(); + + boundaries[rng.random_range(0..boundaries.len())] + } else if roll < BOUNDARY_PERCENT + LITERAL_PERCENT && !literals.is_empty() { + literals[rng.random_range(0..literals.len())] + } else { + rng.random_range(1..=available) + } + } + + /// Target output pairs value can be transferred between: the same asset and a nonzero amount. + fn transfers(&self, transaction: &FinalTransaction, map: &UtxoMap) -> Vec<(usize, usize)> { + let outputs = transaction.outputs(); + + (0..outputs.len()) + .flat_map(|from| (0..outputs.len()).map(move |to| (from, to))) + .filter(|&(from, to)| { + from != to + && outputs[from].asset == outputs[to].asset + && outputs[from].amount > 0 + && (self.targets_output(from, map) || self.targets_output(to, map)) + }) + .collect() + } + + /// Targeted outputs with something to pay. + fn funded_outputs(&self, transaction: &FinalTransaction, map: &UtxoMap) -> Vec { + (0..transaction.n_outputs()) + .filter(|position| self.targets_output(*position, map)) + .filter(|position| transaction.outputs()[*position].amount > 0) + .collect() + } + + fn targets_output(&self, position: usize, map: &UtxoMap) -> bool { + self.mutations.everywhere + || map + .old_output_index(position) + .is_some_and(|template| self.target_outputs.contains(&template)) + } + + fn targets_input(&self, position: usize, map: &UtxoMap) -> bool { + self.mutations.everywhere + || map + .old_input_index(position) + .is_some_and(|template| self.target_inputs.contains(&template)) + } + + /// The assets of the inputs and outputs. + fn assets(transaction: &FinalTransaction) -> Vec { + let inputs = transaction + .inputs() + .iter() + .filter_map(|input| input.partial_input.asset_and_amount().map(|(asset, _)| asset)); + let mut assets: Vec = inputs + .chain(transaction.outputs().iter().map(|output| output.asset)) + .collect(); + + assets.sort_unstable(); + assets.dedup(); + + assets + } + + /// Outputs a program locks and the fee outputs. + fn find_target_outputs(transaction: &FinalTransaction, program_scenarios: &[FuzzProgramScenario]) -> Vec { + (0..transaction.n_outputs()) + .filter(|index| { + transaction.outputs()[*index].script_pubkey.is_empty() + || program_scenarios + .iter() + .any(|program_scenario| program_scenario.locks(*index)) + }) + .collect() + } + + /// Inputs a program spends. + fn find_target_inputs(transaction: &FinalTransaction, program_scenarios: &[FuzzProgramScenario]) -> Vec { + (0..transaction.n_inputs()) + .filter(|index| { + program_scenarios + .iter() + .any(|program_scenario| program_scenario.spends(*index)) + }) + .collect() + } + + /// Program literals that fit a `u64`. + fn literals(program_scenarios: &[FuzzProgramScenario]) -> Vec { + program_scenarios + .iter() + .flat_map(FuzzProgramScenario::literals) + .filter_map(|literal| Word::from(*literal).to_u64()) + .collect() + } + + fn locktime(transaction: &FinalTransaction) -> Result { + let (pst, _) = transaction + .extract_pst() + .map_err(|err| SetupError::Transaction(err.to_string()))?; + + Ok(pst + .extract_tx() + .map_or(0, |transaction| transaction.lock_time.to_consensus_u32())) + } +} + +#[cfg(test)] +mod tests { + use simplicityhl::elements::confidential::{AssetBlindingFactor, ValueBlindingFactor}; + use simplicityhl::elements::{AssetId, OutPoint, Script, TxOut, TxOutSecrets}; + + use smplx_sdk::transaction::partial_input::IssuanceInput; + use smplx_sdk::transaction::{PartialInput, PartialOutput, UTXO}; + + use super::*; + + #[test] + fn mutates_an_unblinded_input() { + let asset = AssetId::default(); + let secrets = TxOutSecrets::new(asset, AssetBlindingFactor::zero(), 10, ValueBlindingFactor::zero()); + + let mut transaction = FinalTransaction::new(); + transaction.add_input( + PartialInput::new(UTXO { + outpoint: OutPoint::default(), + txout: TxOut::default(), + secrets: Some(secrets), + }), + None, + ); + transaction.add_output(PartialOutput::new(Script::new(), 10, asset)); + transaction.add_output(PartialOutput::default()); + + assert!(Mutator::new(Mutations::default(), &transaction, &[]).is_ok()); + } + + #[test] + fn transaction_that_does_not_extract_is_a_setup_error() { + let asset = AssetId::default(); + + // Balanced, but an issuance of nothing can't be extracted + let mut transaction = FinalTransaction::new(); + transaction.add_issuance_input( + PartialInput::new(UTXO::explicit(OutPoint::default(), Script::new(), 10, asset)), + IssuanceInput::new_issuance(0, 0, [0; 32]), + None, + ); + transaction.add_output(PartialOutput::new(Script::new(), 10, asset)); + + assert!(matches!( + Mutator::new(Mutations::default(), &transaction, &[]), + Err(SetupError::Transaction(_)) + )); + } +} diff --git a/crates/test/src/fuzz/mutations/utxo_map.rs b/crates/test/src/fuzz/mutations/utxo_map.rs new file mode 100644 index 00000000..930243cd --- /dev/null +++ b/crates/test/src/fuzz/mutations/utxo_map.rs @@ -0,0 +1,64 @@ +use smplx_sdk::transaction::FinalTransaction; + +use crate::fuzz::mutations::Mutation; + +/// Where each UTXO of the assembled transaction sits after the mutations. +#[derive(Clone, Debug)] +pub(crate) struct UtxoMap { + inputs: Vec, + outputs: Vec, +} + +impl UtxoMap { + pub(crate) fn new(transaction: &FinalTransaction, mutations: &[Mutation]) -> Self { + let mut map = Self { + inputs: (0..transaction.n_inputs()).collect(), + outputs: (0..transaction.n_outputs()).collect(), + }; + + for mutation in mutations { + map.follow(mutation); + } + + map + } + + /// Moves the UTXOs `mutation` reorders. + pub(crate) fn follow(&mut self, mutation: &Mutation) { + match *mutation { + Mutation::SwapInputs(first, second) => Self::swap(&mut self.inputs, first, second), + Mutation::SwapOutputs(first, second) => Self::swap(&mut self.outputs, first, second), + _ => {} + } + } + + /// Where input `old_index` of the assembled transaction sits now. + pub(crate) fn new_input_position(&self, old_index: usize) -> usize { + self.inputs[old_index] + } + + /// Where output `old_index` of the assembled transaction sits now. + pub(crate) fn new_output_position(&self, old_index: usize) -> usize { + self.outputs[old_index] + } + + /// The index in the assembled transaction of the input now at `new_position`, `None` for an added one. + pub(crate) fn old_input_index(&self, new_position: usize) -> Option { + self.inputs.iter().position(|input| *input == new_position) + } + + /// The index in the assembled transaction of the output now at `new_position`, `None` for an added one. + pub(crate) fn old_output_index(&self, new_position: usize) -> Option { + self.outputs.iter().position(|output| *output == new_position) + } + + fn swap(positions: &mut [usize], first: usize, second: usize) { + for position in positions { + if *position == first { + *position = second; + } else if *position == second { + *position = first; + } + } + } +} diff --git a/crates/test/src/fuzz/outcome.rs b/crates/test/src/fuzz/outcome.rs new file mode 100644 index 00000000..368c85a3 --- /dev/null +++ b/crates/test/src/fuzz/outcome.rs @@ -0,0 +1,185 @@ +use std::fmt::{Display, Formatter, Result as FmtResult}; + +use simplicityhl::simplicity::bit_machine::ExecutionError; + +use smplx_sdk::program::ProgramError; + +/// Result of executing one program input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Outcome { + Accepted, + Rejected(Rejection), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum Rejection { + /// A jet failed, e.g. `assert!`. + Assertion, + /// `panic!`. + Panic, + /// A pruned branch was reached, e.g. `unwrap(None)`. + Unwrap, + /// A Bit Machine limit exceeded. + Resource, + /// The program costs more than its witness pays for. + Budget, +} + +/// A broken fuzz setup. Not a statement about the contract. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SetupError { + Scenario(String), + Arguments(String), + Witness(String), + Storage(String), + Transaction(String), + Signing(String), + Engine(String), + Binding(String), + Spec, +} + +impl Rejection { + pub(crate) const ALL: [Self; 5] = [Self::Assertion, Self::Panic, Self::Unwrap, Self::Resource, Self::Budget]; +} + +impl Outcome { + /// # Errors + /// Returns a [`SetupError`] when the error comes from the fuzz setup rather than the contract. + pub(crate) fn classify(result: Result) -> Result { + let Err(error) = result else { + return Ok(Self::Accepted); + }; + + let rejection = match &error { + ProgramError::InsufficientBudget { .. } => Rejection::Budget, + ProgramError::BitMachineCreation(_) => Rejection::Resource, + ProgramError::Pruning(execution) | ProgramError::Execution(execution) => match execution { + ExecutionError::JetFailed(_) => Rejection::Assertion, + ExecutionError::ReachedFailNode(_) => Rejection::Panic, + ExecutionError::ReachedPrunedBranch(_) => Rejection::Unwrap, + ExecutionError::LimitExceeded(_) => Rejection::Resource, + ExecutionError::InputWrongType(_) | ExecutionError::JetTypeMismatch => { + return Err(SetupError::Engine(error.to_string())); + } + }, + ProgramError::Compilation(_) | ProgramError::ProgramGenAbiMeta(_) => { + return Err(SetupError::Arguments(error.to_string())); + } + ProgramError::WitnessSatisfaction(_) => return Err(SetupError::Witness(error.to_string())), + ProgramError::MissingWitnessUtxo { .. } + | ProgramError::UtxoIndexOutOfBounds { .. } + | ProgramError::ScriptPubkeyMismatch { .. } + | ProgramError::TxExtraction(_) + | ProgramError::InputIndexOverflow(_) => return Err(SetupError::Transaction(error.to_string())), + }; + + Ok(Self::Rejected(rejection)) + } + + #[must_use] + pub(crate) fn is_accepted(&self) -> bool { + matches!(self, Self::Accepted) + } +} + +impl Display for Outcome { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + Self::Accepted => write!(f, "accepted"), + Self::Rejected(rejection) => write!(f, "rejected ({rejection})"), + } + } +} + +impl Display for Rejection { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + let reason = match self { + Self::Assertion => "assertion", + Self::Panic => "panic", + Self::Unwrap => "unwrap", + Self::Resource => "resource limit", + Self::Budget => "witness budget", + }; + + write!(f, "{reason}") + } +} + +impl Display for SetupError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + Self::Scenario(error) => write!(f, "the scenario is invalid: {error}"), + Self::Arguments(error) => write!(f, "arguments do not fit the program: {error}"), + Self::Witness(error) => write!(f, "witness does not fit the program: {error}"), + Self::Storage(error) => write!(f, "storage does not fit the program: {error}"), + Self::Transaction(error) => write!(f, "transaction does not fit the program: {error}"), + Self::Signing(error) => write!(f, "signing failed: {error}"), + Self::Engine(error) => write!(f, "program and environment disagree, please report a bug: {error}"), + Self::Binding(name) => write!(f, "a binding or transition of `{name}` panicked, see its message above"), + Self::Spec => write!(f, "the spec panicked, see its message above"), + } + } +} + +#[cfg(test)] +mod tests { + use simplicityhl::{Arguments, WitnessValues}; + + use smplx_sdk::program::{Program, ProgramTrait}; + use smplx_sdk::provider::SimplicityNetwork; + use smplx_sdk::transaction::{FinalTransaction, PartialInput, UTXO}; + + use super::*; + + fn finalize(source: &str) -> Result { + let network = SimplicityNetwork::default_regtest(); + let program = Program::new(source, Arguments::default()); + + let mut utxo = UTXO::default(); + utxo.txout.script_pubkey = program.get_script_pubkey(&network); + + let mut transaction = FinalTransaction::new(); + transaction.add_input(PartialInput::new(utxo), None); + + let (pst, _) = transaction.extract_pst().unwrap(); + + Outcome::classify(program.finalize(&pst, &WitnessValues::default(), 0, &network)) + } + + #[test] + fn classifies_executions() { + assert_eq!(finalize("fn main() { assert!(true); }"), Ok(Outcome::Accepted)); + assert_eq!( + finalize("fn main() { assert!(false); }"), + Ok(Outcome::Rejected(Rejection::Assertion)) + ); + assert_eq!( + finalize("fn main() { panic!(); }"), + Ok(Outcome::Rejected(Rejection::Panic)) + ); + assert_eq!( + finalize("fn main() { let x: Option = None; let y: u8 = unwrap(x); }"), + Ok(Outcome::Rejected(Rejection::Unwrap)) + ); + assert!(matches!( + finalize("fn main() { let w: u8 = witness::W; assert!(jet::eq_8(w, 0)); }"), + Err(SetupError::Witness(_)) + )); + } + + #[test] + fn classifies_budget_as_rejection() { + let error = ProgramError::InsufficientBudget { + cost_wu: 2, + budget_wu: 1, + stack_bytes: 0, + deficit_wu: 1, + }; + + assert_eq!( + Outcome::classify::<()>(Err(error)), + Ok(Outcome::Rejected(Rejection::Budget)) + ); + } +} diff --git a/crates/test/src/fuzz/program.rs b/crates/test/src/fuzz/program.rs new file mode 100644 index 00000000..27c2e454 --- /dev/null +++ b/crates/test/src/fuzz/program.rs @@ -0,0 +1,148 @@ +use simplicityhl::elements::Script; +use simplicityhl::{Arguments, WitnessValues}; + +use smplx_sdk::program::{Program, ProgramFactory, ProgramSchema}; +use smplx_sdk::provider::SimplicityNetwork; + +use crate::fuzz::mutations::UtxoMap; +use crate::fuzz::program_scenario::FuzzProgramScenario; + +/// A program the fuzz engine can build, describe and decode. Implemented for every `include_simf!()` program. +pub trait FuzzProgramTrait: + AsRef + + ProgramFactory + + ProgramSchema< + Arguments: for<'a> TryFrom<&'a Arguments, Error = String> + Into, + Witness: for<'a> TryFrom<&'a WitnessValues, Error = String> + Into, + > + Clone + + 'static +{ + /// Builds and compiles the program. + /// + /// # Errors + /// Returns why the arguments do not fit the program. + fn build_program(arguments: &Arguments) -> Result { + Self::Arguments::try_from(arguments)?; + + let program = (*Self::instantiate_program(arguments.clone())).as_ref().clone(); + program.load().map_err(|error| error.to_string())?; + + Ok(program) + } + + /// # Errors + /// Returns why the witness does not fit the program. + fn check_witness(witness: &WitnessValues) -> Result<(), String> { + Self::Witness::try_from(witness).map(|_| ()) + } +} + +impl

FuzzProgramTrait for P where + P: AsRef + + ProgramFactory

+ + ProgramSchema< + Arguments: for<'a> TryFrom<&'a Arguments, Error = String> + Into, + Witness: for<'a> TryFrom<&'a WitnessValues, Error = String> + Into, + > + Clone + + 'static +{ +} + +/// One fuzzed program as built for a case. +pub struct FuzzProgram<'a, P: FuzzProgramTrait> { + program_scenario: &'a FuzzProgramScenario, + program: &'a Program, + network: &'a SimplicityNetwork, + arguments: P::Arguments, + witness: Option, + raw_witness: Option<&'a WitnessValues>, + inputs: Vec, + outputs: Vec, +} + +impl<'a, P: FuzzProgramTrait> FuzzProgram<'a, P> { + /// `raw_witness` is `None` when the program spends no input. + pub(crate) fn new( + program_scenario: &'a FuzzProgramScenario, + program: &'a Program, + raw_witness: Option<&'a WitnessValues>, + map: &UtxoMap, + network: &'a SimplicityNetwork, + ) -> Self { + Self { + program_scenario, + program, + network, + arguments: P::Arguments::try_from(program.get_arguments()).expect("checked when the program was built"), + witness: raw_witness + .map(|witness| P::Witness::try_from(witness).expect("checked when the program was built")), + raw_witness, + inputs: program_scenario.inputs_in(map), + outputs: program_scenario.outputs_in(map), + } + } + + pub fn name(&self) -> &str { + self.program_scenario.name() + } + + pub fn arguments(&self) -> &P::Arguments { + &self.arguments + } + + /// The witness as generated, before any signature is injected. + /// + /// # Panics + /// Panics when the program spends no input. + pub fn witness(&self) -> &P::Witness { + self.witness + .as_ref() + .unwrap_or_else(|| panic!("`{}` spends no input, so it has no witness", self.name())) + } + + pub fn raw_arguments(&self) -> &Arguments { + self.program.get_arguments() + } + + /// # Panics + /// Panics when the program spends no input. + pub fn raw_witness(&self) -> &WitnessValues { + self.raw_witness + .unwrap_or_else(|| panic!("`{}` spends no input, so it has no witness", self.name())) + } + + pub fn program(&self) -> &Program { + self.program + } + + /// The storage the program is spent with. + pub fn storage(&self) -> Vec<[u8; 32]> { + self.program + .get_storage() + .iter() + .map(|slot| slot.as_slice().try_into().expect("a storage slot is 32 bytes")) + .collect() + } + + pub fn script_pubkey(&self) -> Script { + self.program.get_script_pubkey(self.network) + } + + pub fn script_hash(&self) -> [u8; 32] { + self.program.get_script_hash(self.network) + } + + pub fn cmr(&self) -> [u8; 32] { + self.program.get_cmr() + } + + /// Indices of the inputs the program spends, after the mutations. + pub fn inputs(&self) -> &[usize] { + &self.inputs + } + + /// Indices of the outputs the program locks, after the mutations. + pub fn outputs(&self) -> &[usize] { + &self.outputs + } +} diff --git a/crates/test/src/fuzz/program_scenario.rs b/crates/test/src/fuzz/program_scenario.rs new file mode 100644 index 00000000..5bf48d6b --- /dev/null +++ b/crates/test/src/fuzz/program_scenario.rs @@ -0,0 +1,288 @@ +use std::any::{TypeId, type_name}; +use std::marker::PhantomData; +use std::panic::{self, AssertUnwindSafe}; + +use proptest::prelude::Strategy; + +use simplicityhl::num::U256; +use simplicityhl::{Arguments, ResolvedType, TemplateProgramWitness, WitnessValues}; + +use smplx_sdk::program::Program; +use smplx_sdk::transaction::FinalTransaction; + +use crate::fuzz::case::FuzzCase; +use crate::fuzz::mutations::UtxoMap; +use crate::fuzz::outcome::SetupError; +use crate::fuzz::program::{FuzzProgram, FuzzProgramTrait}; +use crate::fuzz::program_strategy::{FuzzProgramSchema, FuzzProgramStrategy, FuzzProgramValues}; +use crate::fuzz::witness_path::WitnessPath; + +type Binding = Box) -> T>; +type Transition = Box) -> Vec<[u8; 32]>>; + +/// A builder to configure what each FuzzProgram does. +pub struct FuzzProgramScenarioBuilder<'a, P> { + program_scenario: &'a mut FuzzProgramScenario, + _program: PhantomData P>, +} + +/// The FuzzProgram fuzz configuration. +pub(crate) struct FuzzProgramScenario { + pub(crate) name: String, + pub(crate) inputs: Vec, + pub(crate) outputs: Vec, + pub(crate) unchecked_outputs: Vec, + /// Witness fields with the branches the witness must take, e.g. `("PATH", ["Right", "Left"])`. + pub(crate) spending_paths: Vec<(TemplateProgramWitness, WitnessPath)>, + pub(crate) witness_types: Vec<(TemplateProgramWitness, ResolvedType)>, + pub(crate) transitions: Vec<(usize, Transition)>, + pub(crate) storage_capacity: usize, + pub(crate) program_type: (TypeId, &'static str), + pub(crate) strategy: FuzzProgramStrategy, + pub(crate) argument_bindings: Vec>, + pub(crate) witness_bindings: Vec>, + pub(crate) witness_requirements: Vec>, + pub(crate) build_program: fn(&Arguments) -> Result, + pub(crate) check_witness: fn(&WitnessValues) -> Result<(), String>, +} + +impl FuzzProgramScenario { + pub(crate) fn new(name: String) -> Self { + Self { + name, + inputs: Vec::new(), + outputs: Vec::new(), + unchecked_outputs: Vec::new(), + spending_paths: Vec::new(), + witness_types: P::witness_types(), + transitions: Vec::new(), + storage_capacity: 0, + program_type: (TypeId::of::

(), type_name::

()), + strategy: FuzzProgramStrategy::Guided(FuzzProgramSchema::of::

()), + argument_bindings: Vec::new(), + witness_bindings: Vec::new(), + witness_requirements: Vec::new(), + build_program: P::build_program, + check_witness: P::check_witness, + } + } + + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// Whether the program spends input `index` of the assembled transaction. + pub(crate) fn spends(&self, index: usize) -> bool { + self.inputs.contains(&index) + } + + /// Whether the program locks output `index` of the assembled transaction. + pub(crate) fn locks(&self, index: usize) -> bool { + self.outputs.contains(&index) + } + + pub(crate) fn has_requirements(&self) -> bool { + !self.witness_requirements.is_empty() + } + + /// The literals of the program's source, empty for a custom strategy. + pub(crate) fn literals(&self) -> &[U256] { + self.strategy.schema().map_or(&[], |schema| &schema.literals) + } + + /// The strategy generating this program's values, with its storage capacity and spending paths. + pub(crate) fn configured_strategy(&self) -> FuzzProgramStrategy { + match &self.strategy { + FuzzProgramStrategy::Guided(schema) => FuzzProgramStrategy::Guided(self.configure(schema)), + FuzzProgramStrategy::Random(schema) => FuzzProgramStrategy::Random(self.configure(schema)), + FuzzProgramStrategy::Other(strategy) => FuzzProgramStrategy::Other(strategy.clone()), + } + } + + /// Runs `bindings` of this program on `value` in order. + pub(crate) fn run_bindings( + &self, + bindings: &[Binding], + value: T, + case: &FuzzCase<'_>, + ) -> Result { + bindings.iter().try_fold(value, |value, binding| { + panic::catch_unwind(AssertUnwindSafe(|| binding(&value, case))) + .map_err(|_| SetupError::Binding(self.name.clone())) + }) + } + + /// Where the inputs this program spends sit after the mutations. + pub(crate) fn inputs_in(&self, map: &UtxoMap) -> Vec { + self.inputs.iter().map(|&input| map.new_input_position(input)).collect() + } + + /// Where the outputs this program locks sit after the mutations. + pub(crate) fn outputs_in(&self, map: &UtxoMap) -> Vec { + self.outputs + .iter() + .map(|&output| map.new_output_position(output)) + .collect() + } + + /// The witness of the inputs this program spends, `None` when it spends nothing. + pub(crate) fn witness_in<'t>(&self, transaction: &'t FinalTransaction, map: &UtxoMap) -> Option<&'t WitnessValues> { + self.inputs.first().map(|&input| { + &transaction.inputs()[map.new_input_position(input)] + .program_input + .as_ref() + .expect("a spent input runs its program") + .witness + }) + } + + /// Places `witness` into every input this program spends. + pub(crate) fn place_witness(&self, transaction: &mut FinalTransaction, map: &UtxoMap, witness: WitnessValues) { + for input in self.inputs_in(map) { + transaction.inputs_mut()[input] + .program_input + .as_mut() + .expect("a spent input runs its program") + .witness = witness.clone(); + } + } + + fn configure(&self, schema: &FuzzProgramSchema) -> FuzzProgramSchema { + schema + .clone() + .with_storage_capacity(self.storage_capacity) + .with_spending_paths(self.spending_paths.clone()) + } +} + +impl<'a, P: FuzzProgramTrait> FuzzProgramScenarioBuilder<'a, P> { + pub(crate) fn new(program_scenario: &'a mut FuzzProgramScenario) -> Self { + Self { + program_scenario, + _program: PhantomData, + } + } + + /// Spends the transaction input at `index`, overriding its arguments and witness. + pub fn spends(self, index: usize) -> Self { + self.program_scenario.inputs.push(index); + + self + } + + /// Locks the transaction output at `index`, overriding its script. + pub fn locks(self, index: usize) -> Self { + self.program_scenario.outputs.push(index); + + self + } + + /// Locks the transaction output at `index` like [`Self::locks`] but doesn't do the "redirect" check. + /// E.g. for a spend that may legitimately send the coins anywhere. + pub fn locks_unchecked(self, index: usize) -> Self { + self.program_scenario.outputs.push(index); + self.program_scenario.unchecked_outputs.push(index); + + self + } + + /// Specifies the spending path of the program to fuzz. The path must exist in witness + /// and end with "Left" or "Right". + pub fn spending_path(self, name: &str, path: I) -> Self + where + I: IntoIterator, + I::Item: AsRef, + { + self.program_scenario + .spending_paths + .push((TemplateProgramWitness::witness_from_str(name), WitnessPath::new(path))); + + self + } + + /// Locks the transaction output at `index` to this program with the storage `transition` function returns. + pub fn recursive(self, index: usize, transition: impl Fn(&FuzzProgram<'_, P>) -> Vec<[u8; 32]> + 'static) -> Self { + let name = self.program_scenario.name.clone(); + + self.program_scenario.outputs.push(index); + self.program_scenario + .transitions + .push((index, Box::new(move |case| transition(&case.program::

(&name))))); + + self + } + + /// Gives the program `capacity` storage slots, generated like its other values. + /// + /// # Panics + /// Panics if `capacity` exceeds [`Program::MAX_STORAGE_SLOTS`]. + pub fn storage_capacity(self, capacity: usize) -> Self { + // Refused here rather than in the middle of a fuzz run + assert!( + capacity <= Program::MAX_STORAGE_SLOTS, + "A program holds at most {} storage slots, and `{}` asks for {capacity}", + Program::MAX_STORAGE_SLOTS, + self.program_scenario.name + ); + + self.program_scenario.storage_capacity = capacity; + + self + } + + /// Generates the program's arguments and witness uniformly at random. + pub fn random_strategy(self) -> Self { + self.program_scenario.strategy = FuzzProgramStrategy::Random(FuzzProgramSchema::of::

()); + + self + } + + /// Replaces how the program's arguments and witness are generated. + pub fn custom_strategy(self, strategy: impl Strategy + 'static) -> Self { + self.program_scenario.strategy = FuzzProgramStrategy::Other(strategy.boxed()); + + self + } + + /// Sets argument fields before the program is built. `case` holds the programs added before this one. + pub fn bind_arguments(self, bind: impl Fn(&mut P::Arguments, &FuzzCase<'_>) + 'static) -> Self { + self.program_scenario + .argument_bindings + .push(Box::new(move |arguments, case| { + let mut arguments = + P::Arguments::try_from(arguments).unwrap_or_else(|error| panic!("Generated arguments: {error}")); + bind(&mut arguments, case); + + arguments.into() + })); + + self + } + + /// Sets witness fields once every program is built and placed before signing. + pub fn bind_witness(self, bind: impl Fn(&mut P::Witness, &FuzzCase<'_>) + 'static) -> Self { + self.program_scenario.witness_bindings.push(Self::witness_binding(bind)); + + self + } + + /// Sets the only witness values the contract accepts, after the bindings. + /// Required witness is fuzzed and the contract should reject the alternatives. + pub fn requires_witness(self, require: impl Fn(&mut P::Witness, &FuzzCase<'_>) + 'static) -> Self { + self.program_scenario + .witness_requirements + .push(Self::witness_binding(require)); + + self + } + + fn witness_binding(bind: impl Fn(&mut P::Witness, &FuzzCase<'_>) + 'static) -> Binding { + Box::new(move |witness, case| { + let mut witness = + P::Witness::try_from(witness).unwrap_or_else(|error| panic!("Generated witness: {error}")); + bind(&mut witness, case); + + witness.into() + }) + } +} diff --git a/crates/test/src/fuzz/program_strategy/core.rs b/crates/test/src/fuzz/program_strategy/core.rs new file mode 100644 index 00000000..3edf38a8 --- /dev/null +++ b/crates/test/src/fuzz/program_strategy/core.rs @@ -0,0 +1,43 @@ +use simplicityhl::{Arguments, WitnessValues}; + +use proptest::prelude::BoxedStrategy; + +use crate::fuzz::program_strategy::FuzzProgramSchema; + +/// The values a program strategy generates for one program of a case. +#[derive(Clone, Debug, PartialEq)] +pub struct FuzzProgramValues { + pub arguments: Arguments, + pub witness: WitnessValues, + /// One 32-byte value per storage slot. + pub storage: Vec<[u8; 32]>, +} + +impl From<(Arguments, WitnessValues)> for FuzzProgramValues { + /// Values of a program without storage. + fn from((arguments, witness): (Arguments, WitnessValues)) -> Self { + Self { + arguments, + witness, + storage: Vec::new(), + } + } +} + +#[derive(Clone, Debug)] +pub(crate) enum FuzzProgramStrategy { + /// Generated together with the other guided programs of the case. + Guided(FuzzProgramSchema), + Random(FuzzProgramSchema), + Other(BoxedStrategy), +} + +impl FuzzProgramStrategy { + /// The schema the strategy generates from, `None` for a custom strategy. + pub(crate) fn schema(&self) -> Option<&FuzzProgramSchema> { + match self { + Self::Guided(schema) | Self::Random(schema) => Some(schema), + Self::Other(_) => None, + } + } +} diff --git a/crates/test/src/fuzz/args_strategy/guided.rs b/crates/test/src/fuzz/program_strategy/guided.rs similarity index 59% rename from crates/test/src/fuzz/args_strategy/guided.rs rename to crates/test/src/fuzz/program_strategy/guided.rs index 1439dc68..1ff964b6 100644 --- a/crates/test/src/fuzz/args_strategy/guided.rs +++ b/crates/test/src/fuzz/program_strategy/guided.rs @@ -1,105 +1,110 @@ use std::collections::HashMap; -use std::fmt::{Debug, Formatter, Result}; -use std::marker::PhantomData; use simplicityhl::either::Either; -use simplicityhl::num::U256; -use simplicityhl::types::{TypeInner, UIntType}; +use simplicityhl::types::TypeInner; use simplicityhl::value::{ValueConstructible, ValueInner}; use simplicityhl::{Arguments, ResolvedType, TemplateProgramWitness, Value, WitnessNameToValueMap, WitnessValues}; -use proptest::prelude::Strategy; -use proptest::strategy::NewTree; -use proptest::test_runner::{TestRng, TestRunner}; +use proptest::test_runner::TestRng; use rand::RngExt; -use smplx_sdk::program::ProgramSchema; - -use crate::fuzz::args_strategy::FixedValueTree; +use crate::fuzz::program_strategy::{FuzzProgramSchema, FuzzProgramValues, Word}; +use crate::fuzz::scenario_strategy::FuzzTransactionSchema; +use crate::fuzz::witness_path::WitnessPath; // TODO: make the weights configurable. const RANDOM_WEIGHT: u32 = 40; const BOUNDARY_WEIGHT: u32 = 20; const LITERAL_WEIGHT: u32 = 20; +const UTXO_WEIGHT: u32 = 40; const REUSE_PERCENT: u32 = 20; -const BOUNDARY_POWERS: &[usize] = &[4, 5, 6, 7, 8, 10, 12, 15, 16, 31, 32, 63, 64, 127, 128, 255]; -const BOUNDARY_SMALL: &[u128] = &[0, 1, 2, 100, 1000]; - -/// Draws each integer from uniform random, boundary or literal values, then copies values between -/// integers of the same case. -pub struct Guided

{ - _program: PhantomData P>, -} - -impl

Default for Guided

{ - fn default() -> Self { - Self { _program: PhantomData } - } -} - -impl

Debug for Guided

{ - fn fmt(&self, f: &mut Formatter<'_>) -> Result { - writeln!(f, "Guided trees...") - } -} - -impl Strategy for Guided

{ - type Tree = FixedValueTree<(Arguments, WitnessValues)>; - type Value = (Arguments, WitnessValues); - - fn new_tree(&self, runner: &mut TestRunner) -> NewTree { - let case = GuidedCase::new(&P::literals(), runner.rng()).generate(&P::argument_types(), &P::witness_types()); - - Ok(FixedValueTree(case)) - } -} - -/// State of a single generated case. -struct GuidedCase<'a> { +/// State of a single generated case. Programs generated together share literals and values. +pub(crate) struct GuidedCase<'a> { + schemas: &'a [&'a FuzzProgramSchema], literals: Vec, + utxos_max: u32, leaves: Vec<(usize, Word)>, rng: &'a mut TestRng, } impl<'a> GuidedCase<'a> { - fn new(literals: &[U256], rng: &'a mut TestRng) -> Self { + pub(crate) fn new(schemas: &'a [&'a FuzzProgramSchema], rng: &'a mut TestRng) -> Self { Self { - literals: literals.iter().copied().map(Word::from).collect(), + schemas, + literals: schemas + .iter() + .flat_map(|schema| schema.literals.iter().copied().map(Word::from)) + .collect(), + utxos_max: 0, leaves: Vec::new(), rng, } } - fn generate( - mut self, - argument_types: &[(TemplateProgramWitness, ResolvedType)], - witness_types: &[(TemplateProgramWitness, ResolvedType)], - ) -> (Arguments, WitnessValues) { - let arguments = self.draw_fields(argument_types); - let witness = self.draw_fields(witness_types); + /// Draws from the transaction's values too: its literals join the pool and indices up to `utxos_max` come up often. + #[must_use] + pub(crate) fn with_transaction_schema(mut self, schema: &FuzzTransactionSchema) -> Self { + self.literals.extend(schema.literals.iter().copied().map(Word::from)); + self.utxos_max = schema.utxos_max; - let mut words = self.reuse().into_iter(); - let mut next_word = |uint: UIntType| words.next().expect("one word per leaf").to_value(uint); + self + } - let arguments: HashMap<_, _> = arguments - .into_iter() - .map(|(name, value)| (name, Self::map_uints(&value, &mut next_word))) + /// The values of each schema, in order. Storage slots are drawn like `u256` leaves. + pub(crate) fn generate(mut self) -> Vec { + let schemas = self.schemas; + let fields: Vec<_> = schemas + .iter() + .map(|schema| { + let arguments = self.draw_fields(&schema.argument_types, |_| WitnessPath::empty()); + let witness = self.draw_fields(&schema.witness_types, |field| schema.spending_path(field)); + + for _ in 0..schema.storage_capacity { + let word = self.draw_word(256); + self.leaves.push((256, word)); + } + + (arguments, witness, schema.storage_capacity) + }) .collect(); - let witness: HashMap<_, _> = witness + + let mut words = self.reuse().into_iter(); + + fields .into_iter() - .map(|(name, value)| (name, Self::map_uints(&value, &mut next_word))) - .collect(); + .map(|(arguments, witness, storage_capacity)| { + let arguments: HashMap<_, _> = arguments + .into_iter() + .map(|(name, value)| (name, Self::map_uints(&value, &mut words))) + .collect(); + let witness: HashMap<_, _> = witness + .into_iter() + .map(|(name, value)| (name, Self::map_uints(&value, &mut words))) + .collect(); - (Arguments::from_map(arguments), WitnessValues::from_map(witness)) + FuzzProgramValues { + arguments: Arguments::from_map(arguments), + witness: WitnessValues::from_map(witness), + storage: (0..storage_capacity) + .map(|_| words.next().expect("one word per leaf").to_byte_array()) + .collect(), + } + }) + .collect() } - fn draw_fields( + /// Each field takes the branches of the given path. + fn draw_fields<'p>( &mut self, types: &[(TemplateProgramWitness, ResolvedType)], + path_of: impl Fn(&TemplateProgramWitness) -> &'p WitnessPath, ) -> Vec<(TemplateProgramWitness, Value)> { - types.iter().map(|(name, ty)| (name.clone(), self.draw(ty))).collect() + types + .iter() + .map(|(name, ty)| (name.clone(), path_of(name).draw(ty, &mut |ty| self.draw(ty)))) + .collect() } fn draw(&mut self, ty: &ResolvedType) -> Value { @@ -155,12 +160,19 @@ impl<'a> GuidedCase<'a> { fn draw_word(&mut self, bits: usize) -> Word { let fitting: Vec = self.literals.iter().copied().filter(|word| word.fits(bits)).collect(); let literal_weight = if fitting.is_empty() { 0 } else { LITERAL_WEIGHT }; + let utxo_weight = if self.utxos_max > 0 && Word::from_u128(self.utxos_max.into()).fits(bits) { + UTXO_WEIGHT + } else { + 0 + }; let roll = self .rng - .random_range(0..RANDOM_WEIGHT + BOUNDARY_WEIGHT + literal_weight); + .random_range(0..RANDOM_WEIGHT + BOUNDARY_WEIGHT + literal_weight + utxo_weight); - if roll < RANDOM_WEIGHT { + if roll >= RANDOM_WEIGHT + BOUNDARY_WEIGHT + literal_weight { + Word::from_u128(self.rng.random_range(0..=self.utxos_max).into()) + } else if roll < RANDOM_WEIGHT { Word::random(bits, self.rng) } else if roll < RANDOM_WEIGHT + BOUNDARY_WEIGHT { let boundaries = Word::boundaries(bits); @@ -205,26 +217,26 @@ impl<'a> GuidedCase<'a> { } /// Rebuilds `value`, replacing every integer leaf in traversal order. - fn map_uints(value: &Value, next: &mut impl FnMut(UIntType) -> Value) -> Value { + fn map_uints(value: &Value, words: &mut impl Iterator) -> Value { match (value.inner(), value.ty().as_inner()) { - (ValueInner::UInt(_), TypeInner::UInt(uint)) => next(*uint), + (ValueInner::UInt(_), TypeInner::UInt(uint)) => words.next().expect("one word per leaf").to_value(*uint), (ValueInner::Either(either), TypeInner::Either(left_ty, right_ty)) => match either { - Either::Left(left) => Value::left(Self::map_uints(left, next), (**right_ty).clone()), - Either::Right(right) => Value::right((**left_ty).clone(), Self::map_uints(right, next)), + Either::Left(left) => Value::left(Self::map_uints(left, words), (**right_ty).clone()), + Either::Right(right) => Value::right((**left_ty).clone(), Self::map_uints(right, words)), }, - (ValueInner::Option(Some(inner)), _) => Value::some(Self::map_uints(inner, next)), + (ValueInner::Option(Some(inner)), _) => Value::some(Self::map_uints(inner, words)), (ValueInner::Tuple(elements), _) => { - let values: Vec<_> = elements.iter().map(|el| Self::map_uints(el, next)).collect(); + let values: Vec<_> = elements.iter().map(|el| Self::map_uints(el, words)).collect(); Value::tuple(values) } (ValueInner::Array(elements), TypeInner::Array(element_ty, _)) => { - let values: Vec<_> = elements.iter().map(|el| Self::map_uints(el, next)).collect(); + let values: Vec<_> = elements.iter().map(|el| Self::map_uints(el, words)).collect(); Value::array(values, (**element_ty).clone()) } (ValueInner::List(elements, bound), TypeInner::List(element_ty, _)) => { - let values: Vec<_> = elements.iter().map(|el| Self::map_uints(el, next)).collect(); + let values: Vec<_> = elements.iter().map(|el| Self::map_uints(el, words)).collect(); Value::list(values, (**element_ty).clone(), *bound) } @@ -233,132 +245,15 @@ impl<'a> GuidedCase<'a> { } } -/// Big-endian 256-bit unsigned integer. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -struct Word([u8; 32]); - -impl From for Word { - fn from(value: U256) -> Self { - Self(value.to_byte_array()) - } -} - -impl Word { - fn from_u128(value: u128) -> Self { - let mut bytes = [0; 32]; - bytes[16..].copy_from_slice(&value.to_be_bytes()); - - Self(bytes) - } - - fn pow2(power: usize) -> Self { - let mut bytes = [0; 32]; - bytes[31 - power / 8] = 1 << (power % 8); - - Self(bytes) - } - - fn max(bits: usize) -> Self { - Self([0xff; 32]).masked(bits) - } - - fn random(bits: usize, rng: &mut TestRng) -> Self { - Self(rng.random()).masked(bits) - } - - fn boundaries(bits: usize) -> Vec { - let mut words: Vec = BOUNDARY_SMALL.iter().map(|small| Self::from_u128(*small)).collect(); - - for power in BOUNDARY_POWERS.iter().copied().filter(|power| *power < bits) { - let word = Self::pow2(power); - - words.push(word); - words.extend(word.sub_one()); - } - - let max = Self::max(bits); - - words.push(max); - words.extend(max.sub_one()); - - words.retain(|word| word.fits(bits)); - words.sort_unstable(); - words.dedup(); - - words - } - - /// Clears every bit at or above `bits`. - fn masked(mut self, bits: usize) -> Self { - for (index, byte) in self.0.iter_mut().rev().enumerate() { - let low = index * 8; - - if low >= bits { - *byte = 0; - } else if bits - low < 8 { - *byte &= (1 << (bits - low)) - 1; - } - } - - self - } - - fn fits(&self, bits: usize) -> bool { - *self <= Self::max(bits) - } - - fn add_one(mut self) -> Option { - for byte in self.0.iter_mut().rev() { - let (sum, overflow) = byte.overflowing_add(1); - *byte = sum; - - if !overflow { - return Some(self); - } - } - - None - } - - fn sub_one(mut self) -> Option { - for byte in self.0.iter_mut().rev() { - let (difference, underflow) = byte.overflowing_sub(1); - *byte = difference; - - if !underflow { - return Some(self); - } - } - - None - } - - fn to_value(self, uint: UIntType) -> Value { - let low = u128::from_be_bytes(self.0[16..].try_into().expect("16 bytes")); - - match uint { - UIntType::U1 => Value::u1(low as u8), - UIntType::U2 => Value::u2(low as u8), - UIntType::U4 => Value::u4(low as u8), - UIntType::U8 => Value::u8(low as u8), - UIntType::U16 => Value::u16(low as u16), - UIntType::U32 => Value::u32(low as u32), - UIntType::U64 => Value::u64(low as u64), - UIntType::U128 => Value::u128(low), - UIntType::U256 => Value::u256(U256::from_byte_array(self.0)), - } - } -} - #[cfg(test)] mod tests { - use proptest::prelude::Strategy; - use proptest::strategy::ValueTree; - use proptest::test_runner::{Config, RngSeed}; + use proptest::test_runner::{Config, RngSeed, TestRunner}; - use simplicityhl::num::NonZeroPow2Usize; + use simplicityhl::num::{NonZeroPow2Usize, U256}; use simplicityhl::types::TypeConstructible; + use smplx_sdk::program::ProgramSchema; + use super::*; struct RichSchema; @@ -413,6 +308,9 @@ mod tests { } impl ProgramSchema for RichSchema { + type Arguments = (); + type Witness = (); + fn argument_types() -> Vec<(TemplateProgramWitness, ResolvedType)> { Self::types(TemplateProgramWitness::parameter_from_str) } @@ -433,6 +331,9 @@ mod tests { struct MagicSchema; impl ProgramSchema for MagicSchema { + type Arguments = (); + type Witness = (); + fn argument_types() -> Vec<(TemplateProgramWitness, ResolvedType)> { Vec::new() } @@ -446,16 +347,23 @@ mod tests { } } + fn generate(runner: &mut TestRunner) -> FuzzProgramValues { + let schema = FuzzProgramSchema::of::

(); + + GuidedCase::new(&[&schema], runner.rng()) + .generate() + .pop() + .expect("one schema") + } + #[test] fn strategy_is_persistent() { - let strategy = Guided::::default(); - let mut runner_1 = RichSchema::runner(); let mut runner_2 = RichSchema::runner(); for _ in 0..1024 { - let first = strategy.new_tree(&mut runner_1).unwrap().current(); - let second = strategy.new_tree(&mut runner_2).unwrap().current(); + let first = generate::(&mut runner_1); + let second = generate::(&mut runner_2); assert_eq!(first, second, "same seed produced a different guided case"); } @@ -463,27 +371,25 @@ mod tests { #[test] fn values_match_their_declared_types() { - let strategy = Guided::::default(); let mut runner = RichSchema::runner(); for _ in 0..1024 { - let (arguments, witness) = strategy.new_tree(&mut runner).unwrap().current(); + let values = generate::(&mut runner); - RichSchema::assert_matches(&arguments, &witness); + RichSchema::assert_matches(&values.arguments, &values.witness); } } #[test] fn literals_are_reached_quickly() { - let strategy = Guided::::default(); let mut runner = RichSchema::runner(); let magic = Value::u16(1337); let name = TemplateProgramWitness::witness_from_str("VALUE"); let found = (0..100).any(|_| { - let (_, witness) = strategy.new_tree(&mut runner).unwrap().current(); + let values = generate::(&mut runner); - witness.get(&name) == Some(&magic) + values.witness.get(&name) == Some(&magic) }); assert!(found); @@ -498,7 +404,9 @@ mod tests { for _ in 0..1024 { let words = GuidedCase { + schemas: &[], literals: Vec::new(), + utxos_max: 0, leaves: leaves.clone(), rng: runner.rng(), } @@ -514,26 +422,6 @@ mod tests { assert!(copied); } - #[test] - fn boundaries_cover_the_full_width() { - let max = Word([0xff; 32]); - - assert!(Word::boundaries(256).contains(&max)); - assert!(Word::boundaries(256).contains(&Word::pow2(255))); - assert_eq!(Word::boundaries(1), vec![Word::from_u128(0), Word::from_u128(1)]); - assert!(Word::boundaries(8).iter().all(|word| word.fits(8))); - } - - #[test] - fn word_arithmetic() { - assert_eq!(Word::from_u128(255).add_one(), Some(Word::from_u128(256))); - assert_eq!(Word::from_u128(256).sub_one(), Some(Word::from_u128(255))); - assert_eq!(Word([0xff; 32]).add_one(), None); - assert_eq!(Word::from_u128(0).sub_one(), None); - assert!(Word::from_u128(15).fits(4)); - assert!(!Word::from_u128(16).fits(4)); - } - #[test] fn map_uints_preserves_structure() { let ty = ResolvedType::tuple([ @@ -544,9 +432,7 @@ mod tests { Value::left(Value::u8(1), ResolvedType::boolean()), Value::some(Value::u16(2)), ]); - let mut next = |uint: UIntType| Word::from_u128(3).to_value(uint); - - let mapped = GuidedCase::map_uints(&value, &mut next); + let mapped = GuidedCase::map_uints(&value, &mut std::iter::repeat(Word::from_u128(3))); assert!(mapped.is_of_type(&ty)); assert_eq!( diff --git a/crates/test/src/fuzz/program_strategy/mod.rs b/crates/test/src/fuzz/program_strategy/mod.rs new file mode 100644 index 00000000..8b882a90 --- /dev/null +++ b/crates/test/src/fuzz/program_strategy/mod.rs @@ -0,0 +1,12 @@ +mod core; +mod guided; +mod program_schema; +mod random; +mod word; + +pub(crate) use core::FuzzProgramStrategy; +pub use core::FuzzProgramValues; +pub(crate) use guided::GuidedCase; +pub(crate) use program_schema::FuzzProgramSchema; +pub(crate) use random::RandomCase; +pub(crate) use word::Word; diff --git a/crates/test/src/fuzz/program_strategy/program_schema.rs b/crates/test/src/fuzz/program_strategy/program_schema.rs new file mode 100644 index 00000000..6c9b09b1 --- /dev/null +++ b/crates/test/src/fuzz/program_strategy/program_schema.rs @@ -0,0 +1,50 @@ +use simplicityhl::num::U256; +use simplicityhl::{ResolvedType, TemplateProgramWitness}; + +use smplx_sdk::program::ProgramSchema; + +use crate::fuzz::witness_path::WitnessPath; + +/// What a strategy needs to know about one program. +#[derive(Clone, Debug)] +pub(crate) struct FuzzProgramSchema { + pub(crate) argument_types: Vec<(TemplateProgramWitness, ResolvedType)>, + pub(crate) witness_types: Vec<(TemplateProgramWitness, ResolvedType)>, + pub(crate) literals: Vec, + pub(crate) storage_capacity: usize, + pub(crate) spending_paths: Vec<(TemplateProgramWitness, WitnessPath)>, +} + +impl FuzzProgramSchema { + pub(crate) fn of() -> Self { + Self { + argument_types: P::argument_types(), + witness_types: P::witness_types(), + literals: P::literals(), + storage_capacity: 0, + spending_paths: Vec::new(), + } + } + + #[must_use] + pub(crate) fn with_storage_capacity(mut self, storage_capacity: usize) -> Self { + self.storage_capacity = storage_capacity; + + self + } + + #[must_use] + pub(crate) fn with_spending_paths(mut self, spending_paths: Vec<(TemplateProgramWitness, WitnessPath)>) -> Self { + self.spending_paths = spending_paths; + + self + } + + /// The spending path of `field`, empty when there is none. + pub(crate) fn spending_path(&self, field: &TemplateProgramWitness) -> &WitnessPath { + self.spending_paths + .iter() + .find(|(pinned, _)| pinned == field) + .map_or(WitnessPath::empty(), |(_, path)| path) + } +} diff --git a/crates/test/src/fuzz/args_strategy/random.rs b/crates/test/src/fuzz/program_strategy/random.rs similarity index 64% rename from crates/test/src/fuzz/args_strategy/random.rs rename to crates/test/src/fuzz/program_strategy/random.rs index 298da9b7..99c8516d 100644 --- a/crates/test/src/fuzz/args_strategy/random.rs +++ b/crates/test/src/fuzz/program_strategy/random.rs @@ -1,55 +1,56 @@ -use std::fmt::{Debug, Formatter, Result}; -use std::marker::PhantomData; +use simplicityhl::{Arguments, WitnessNameToValueMap, WitnessValues}; -use simplicityhl::{Arguments, WitnessValues}; +use proptest::test_runner::TestRng; -use proptest::prelude::Strategy; -use proptest::strategy::NewTree; -use proptest::test_runner::TestRunner; +use rand::RngExt; -use smplx_sdk::program::ProgramSchema; +use crate::fuzz::program_strategy::{FuzzProgramSchema, FuzzProgramValues}; +use crate::fuzz::utils::{generate_random_value_by_ty, random_fields}; -use crate::fuzz::args_strategy::FixedValueTree; -use crate::fuzz::utils::{random_arguments, random_witness}; - -pub struct Random

{ - _program: PhantomData P>, +/// A single randomly generated case. +pub(crate) struct RandomCase<'a> { + schema: &'a FuzzProgramSchema, + rng: &'a mut TestRng, } -impl

Default for Random

{ - fn default() -> Self { - Self { _program: PhantomData } +impl<'a> RandomCase<'a> { + pub(crate) fn new(schema: &'a FuzzProgramSchema, rng: &'a mut TestRng) -> Self { + Self { schema, rng } } -} -impl

Debug for Random

{ - fn fmt(&self, f: &mut Formatter<'_>) -> Result { - writeln!(f, "Random trees...") - } -} + pub(crate) fn generate(self) -> FuzzProgramValues { + let witness = self + .schema + .witness_types + .iter() + .map(|(name, ty)| { + let path = self.schema.spending_path(name); -impl Strategy for Random

{ - type Tree = FixedValueTree<(Arguments, WitnessValues)>; - type Value = (Arguments, WitnessValues); - - fn new_tree(&self, runner: &mut TestRunner) -> NewTree { - let arguments = random_arguments::(runner.rng()); - let witness = random_witness::(runner.rng()); + ( + name.clone(), + path.draw(ty, &mut |ty| generate_random_value_by_ty(ty, self.rng)), + ) + }) + .collect(); - Ok(FixedValueTree((arguments, witness))) + FuzzProgramValues { + arguments: Arguments::from_map(random_fields(&self.schema.argument_types, self.rng)), + witness: WitnessValues::from_map(witness), + storage: (0..self.schema.storage_capacity).map(|_| self.rng.random()).collect(), + } } } #[cfg(test)] mod tests { - use proptest::prelude::Strategy; - use proptest::strategy::ValueTree; use proptest::test_runner::{Config, RngSeed, TestRunner}; use simplicityhl::num::{NonZeroPow2Usize, U256}; use simplicityhl::types::TypeConstructible; use simplicityhl::{ResolvedType, TemplateProgramWitness, WitnessNameToValueMap}; + use smplx_sdk::program::ProgramSchema; + use super::*; struct RichSchema; @@ -104,6 +105,9 @@ mod tests { } impl ProgramSchema for RichSchema { + type Arguments = (); + type Witness = (); + fn argument_types() -> Vec<(TemplateProgramWitness, ResolvedType)> { Self::types(TemplateProgramWitness::parameter_from_str) } @@ -117,16 +121,18 @@ mod tests { } } + fn generate(runner: &mut TestRunner) -> FuzzProgramValues { + RandomCase::new(&FuzzProgramSchema::of::(), runner.rng()).generate() + } + #[test] fn strategy_is_persistent() { - let strategy = Random::::default(); - let mut runner_1 = RichSchema::runner(); let mut runner_2 = RichSchema::runner(); for _ in 0..1024 { - let first = strategy.new_tree(&mut runner_1).unwrap().current(); - let second = strategy.new_tree(&mut runner_2).unwrap().current(); + let first = generate(&mut runner_1); + let second = generate(&mut runner_2); assert_eq!(first, second, "same seed produced a different random case"); } @@ -134,13 +140,12 @@ mod tests { #[test] fn values_match_their_declared_types() { - let strategy = Random::::default(); let mut runner = RichSchema::runner(); for _ in 0..1024 { - let (arguments, witness) = strategy.new_tree(&mut runner).unwrap().current(); + let values = generate(&mut runner); - RichSchema::assert_matches(&arguments, &witness); + RichSchema::assert_matches(&values.arguments, &values.witness); } } } diff --git a/crates/test/src/fuzz/program_strategy/word.rs b/crates/test/src/fuzz/program_strategy/word.rs new file mode 100644 index 00000000..7897c2b4 --- /dev/null +++ b/crates/test/src/fuzz/program_strategy/word.rs @@ -0,0 +1,163 @@ +use proptest::test_runner::TestRng; + +use rand::RngExt; + +use simplicityhl::Value; +use simplicityhl::num::U256; +use simplicityhl::types::UIntType; +use simplicityhl::value::ValueConstructible; + +const BOUNDARY_POWERS: &[usize] = &[4, 5, 6, 7, 8, 10, 12, 15, 16, 31, 32, 63, 64, 127, 128, 255]; +const BOUNDARY_SMALL: &[u128] = &[0, 1, 2, 100, 1000]; + +/// Big-endian 256-bit unsigned integer. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct Word([u8; 32]); + +impl From for Word { + fn from(value: U256) -> Self { + Self(value.to_byte_array()) + } +} + +impl Word { + pub(crate) fn from_u128(value: u128) -> Self { + let mut bytes = [0; 32]; + bytes[16..].copy_from_slice(&value.to_be_bytes()); + + Self(bytes) + } + + fn pow2(power: usize) -> Self { + let mut bytes = [0; 32]; + bytes[31 - power / 8] = 1 << (power % 8); + + Self(bytes) + } + + fn max(bits: usize) -> Self { + Self([0xff; 32]).masked(bits) + } + + pub(crate) fn random(bits: usize, rng: &mut TestRng) -> Self { + Self(rng.random()).masked(bits) + } + + pub(crate) fn boundaries(bits: usize) -> Vec { + let mut words: Vec = BOUNDARY_SMALL.iter().map(|small| Self::from_u128(*small)).collect(); + + for power in BOUNDARY_POWERS.iter().copied().filter(|power| *power < bits) { + let word = Self::pow2(power); + + words.push(word); + words.extend(word.sub_one()); + } + + let max = Self::max(bits); + + words.push(max); + words.extend(max.sub_one()); + + words.retain(|word| word.fits(bits)); + words.sort_unstable(); + words.dedup(); + + words + } + + /// Clears every bit at or above `bits`. + fn masked(mut self, bits: usize) -> Self { + for (index, byte) in self.0.iter_mut().rev().enumerate() { + let low = index * 8; + + if low >= bits { + *byte = 0; + } else if bits - low < 8 { + *byte &= (1 << (bits - low)) - 1; + } + } + + self + } + + pub(crate) fn fits(&self, bits: usize) -> bool { + *self <= Self::max(bits) + } + + pub(crate) fn add_one(mut self) -> Option { + for byte in self.0.iter_mut().rev() { + let (sum, overflow) = byte.overflowing_add(1); + *byte = sum; + + if !overflow { + return Some(self); + } + } + + None + } + + pub(crate) fn sub_one(mut self) -> Option { + for byte in self.0.iter_mut().rev() { + let (difference, underflow) = byte.overflowing_sub(1); + *byte = difference; + + if !underflow { + return Some(self); + } + } + + None + } + + pub(crate) fn to_byte_array(self) -> [u8; 32] { + self.0 + } + + /// The word as a `u64`, `None` when it does not fit. + pub(crate) fn to_u64(self) -> Option { + self.fits(64) + .then(|| u64::from_be_bytes(self.0[24..].try_into().expect("8 bytes"))) + } + + pub(crate) fn to_value(self, uint: UIntType) -> Value { + let low = u128::from_be_bytes(self.0[16..].try_into().expect("16 bytes")); + + match uint { + UIntType::U1 => Value::u1(low as u8), + UIntType::U2 => Value::u2(low as u8), + UIntType::U4 => Value::u4(low as u8), + UIntType::U8 => Value::u8(low as u8), + UIntType::U16 => Value::u16(low as u16), + UIntType::U32 => Value::u32(low as u32), + UIntType::U64 => Value::u64(low as u64), + UIntType::U128 => Value::u128(low), + UIntType::U256 => Value::u256(U256::from_byte_array(self.0)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn boundaries_cover_the_full_width() { + let max = Word([0xff; 32]); + + assert!(Word::boundaries(256).contains(&max)); + assert!(Word::boundaries(256).contains(&Word::pow2(255))); + assert_eq!(Word::boundaries(1), vec![Word::from_u128(0), Word::from_u128(1)]); + assert!(Word::boundaries(8).iter().all(|word| word.fits(8))); + } + + #[test] + fn word_arithmetic() { + assert_eq!(Word::from_u128(255).add_one(), Some(Word::from_u128(256))); + assert_eq!(Word::from_u128(256).sub_one(), Some(Word::from_u128(255))); + assert_eq!(Word([0xff; 32]).add_one(), None); + assert_eq!(Word::from_u128(0).sub_one(), None); + assert!(Word::from_u128(15).fits(4)); + assert!(!Word::from_u128(16).fits(4)); + } +} diff --git a/crates/test/src/fuzz/requirement.rs b/crates/test/src/fuzz/requirement.rs new file mode 100644 index 00000000..757bdcf1 --- /dev/null +++ b/crates/test/src/fuzz/requirement.rs @@ -0,0 +1,131 @@ +use std::collections::HashMap; +use std::fmt::{Display, Formatter, Result as FmtResult}; + +use simplicityhl::either::Either; +use simplicityhl::value::ValueInner; +use simplicityhl::{TemplateProgramWitness, Value, WitnessNameToValueMap, WitnessValues}; + +use smplx_sdk::transaction::FinalTransaction; + +use crate::fuzz::mutations::UtxoMap; +use crate::fuzz::program_scenario::FuzzProgramScenario; +use crate::fuzz::witness_path::WitnessPath; + +/// The witness values the requirements of a case set. +pub(crate) struct Requirements<'a> { + values: Vec>, +} + +/// A witness value a requirement sets, with the generated value it replaces. +pub(crate) struct RequiredValue<'a> { + program_scenario: &'a FuzzProgramScenario, + field: TemplateProgramWitness, + path: WitnessPath, + required: Value, + generated: Value, +} + +impl<'a> Requirements<'a> { + pub(crate) fn new() -> Self { + Self { values: Vec::new() } + } + + /// Records every value of `required` that differs from `generated`, down to `Either` branches and tuple elements. + pub(crate) fn record_diff( + &mut self, + program_scenario: &'a FuzzProgramScenario, + generated: &WitnessValues, + required: &WitnessValues, + ) { + let mut fields: Vec<_> = required.iter().collect(); + fields.sort_by_key(|(field, _)| *field); + + for (field, required) in fields { + if let Some(generated) = generated.get(field) { + self.record_value(RequiredValue { + program_scenario, + field: field.clone(), + path: WitnessPath::default(), + required: required.clone(), + generated: generated.clone(), + }); + } + } + } + + /// Puts the generated value back into `transaction` for the required value `pick` chooses. + pub(crate) fn violate_one( + mut self, + pick: Option, + transaction: &mut FinalTransaction, + map: &UtxoMap, + ) -> Option> { + let pick = pick.filter(|_| !self.values.is_empty())?; + let violated = self.values.swap_remove((pick % self.values.len() as u64) as usize); + + let witness = violated + .program_scenario + .witness_in(transaction, map) + .expect("a required value is in the witness of a spent input"); + let mut fields: HashMap<_, _> = witness + .iter() + .map(|(field, value)| (field.clone(), value.clone())) + .collect(); + let value = violated + .path + .replace(&fields[&violated.field], violated.generated.clone()); + + fields.insert(violated.field.clone(), value); + violated + .program_scenario + .place_witness(transaction, map, WitnessValues::from_map(fields)); + + Some(violated) + } + + fn record_value(&mut self, value: RequiredValue<'a>) { + let routes: Vec<(String, Value, Value)> = match (value.generated.inner(), value.required.inner()) { + (ValueInner::Either(Either::Left(generated)), ValueInner::Either(Either::Left(required))) => { + vec![("Left".to_string(), (**generated).clone(), (**required).clone())] + } + (ValueInner::Either(Either::Right(generated)), ValueInner::Either(Either::Right(required))) => { + vec![("Right".to_string(), (**generated).clone(), (**required).clone())] + } + (ValueInner::Tuple(generated), ValueInner::Tuple(required)) => generated + .iter() + .zip(required.iter()) + .enumerate() + .map(|(index, (generated, required))| (index.to_string(), generated.clone(), required.clone())) + .collect(), + _ => { + if value.generated != value.required { + self.values.push(value); + } + + return; + } + }; + + for (route, generated, required) in routes { + self.record_value(RequiredValue { + program_scenario: value.program_scenario, + field: value.field.clone(), + path: value.path.join(route), + required, + generated, + }); + } + } +} + +impl Display for RequiredValue<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + write!(f, "`{}` witness `{}`", self.program_scenario.name(), self.field)?; + + if !self.path.is_empty() { + write!(f, " at {}", self.path)?; + } + + write!(f, " is {} instead of {}", self.generated, self.required) + } +} diff --git a/crates/test/src/fuzz/scenario.rs b/crates/test/src/fuzz/scenario.rs new file mode 100644 index 00000000..8280d03b --- /dev/null +++ b/crates/test/src/fuzz/scenario.rs @@ -0,0 +1,621 @@ +use std::any::{TypeId, type_name}; +use std::collections::{HashMap, HashSet}; +use std::panic::{self, AssertUnwindSafe}; + +use simplicityhl::WitnessNameToValueMap; + +use smplx_sdk::program::Program; +use smplx_sdk::signer::Signer; +use smplx_sdk::transaction::{FinalTransaction, PartialInput, ProgramInput}; + +use crate::fuzz::FuzzProgramTrait; +use crate::fuzz::case::FuzzCase; +use crate::fuzz::mutations::{Mutation, Mutations, UtxoMap}; +use crate::fuzz::outcome::SetupError; +use crate::fuzz::program_scenario::{FuzzProgramScenario, FuzzProgramScenarioBuilder}; +use crate::fuzz::program_strategy::FuzzProgramValues; +use crate::fuzz::requirement::{RequiredValue, Requirements}; +use crate::fuzz::scenario_strategy::{CaseChange, CaseValues, CombinedStrategy}; + +/// A transaction and the programs being fuzzed. +pub struct Scenario { + transaction: FinalTransaction, + program_scenarios: Vec, +} + +impl Scenario { + /// Fuzzes the programs added to `transaction`. Everything they do not override stays as assembled. + pub fn new(transaction: FinalTransaction) -> Self { + Self { + transaction, + program_scenarios: Vec::new(), + } + } + + pub fn add(&mut self, name: impl Into) -> FuzzProgramScenarioBuilder<'_, P> { + self.program_scenarios.push(FuzzProgramScenario::new::

(name.into())); + + FuzzProgramScenarioBuilder::new(self.program_scenarios.last_mut().expect("just added")) + } + + /// # Errors + /// Returns a [`SetupError`] when `mutations` cannot mutate the transaction. + pub(crate) fn combined_strategy(&self, mutations: Option<&Mutations>) -> Result { + CombinedStrategy::new(&self.program_scenarios, &self.transaction, mutations) + } + + /// Where each UTXO of the assembled transaction sits after `mutations`. + pub(crate) fn utxo_map(&self, mutations: &[Mutation]) -> UtxoMap { + UtxoMap::new(&self.transaction, mutations) + } + + /// # Errors + /// Returns [`SetupError::Scenario`] when the programs do not fit the transaction or each other. + pub(crate) fn validate(&self) -> Result<(), SetupError> { + let invalid = |reason: String| Err(SetupError::Scenario(reason)); + + let mut names = HashSet::new(); + let mut spent = HashMap::new(); + let mut locked = HashMap::new(); + + for program_scenario in &self.program_scenarios { + let name = &program_scenario.name; + + if !names.insert(name) { + return invalid(format!("two programs are named `{name}`")); + } + + for (field, path) in &program_scenario.spending_paths { + if !path.ends_at_branch() { + return invalid(format!( + "`{name}` has the spending path {path} in `{field}`, which must end at an `Either` branch" + )); + } + + let fits = program_scenario + .witness_types + .iter() + .find(|(witness, _)| witness == field) + .is_some_and(|(_, ty)| path.fits(ty)); + + if !fits { + return invalid(format!( + "`{name}` has no witness field `{field}` with the spending path {path}" + )); + } + } + + if program_scenario.inputs.is_empty() + && !(program_scenario.witness_bindings.is_empty() && program_scenario.witness_requirements.is_empty()) + { + return invalid(format!("`{name}` binds or requires its witness, but spends no input")); + } + + for &index in &program_scenario.inputs { + if index >= self.transaction.n_inputs() { + return invalid(format!( + "`{name}` spends input {index}, but the transaction has {} input(s)", + self.transaction.n_inputs() + )); + } + + if let Some(other) = spent.insert(index, name) { + return invalid(format!("input {index} is spent by both `{other}` and `{name}`")); + } + } + + for &index in &program_scenario.outputs { + if index >= self.transaction.n_outputs() { + return invalid(format!( + "`{name}` locks output {index}, but the transaction has {} output(s)", + self.transaction.n_outputs() + )); + } + + if let Some(other) = locked.insert(index, name) { + return invalid(format!("output {index} is locked by both `{other}` and `{name}`")); + } + } + } + + if spent.is_empty() { + return invalid("no program spends an input".to_string()); + } + + Ok(()) + } + + /// Builds one case: applies its mutations, builds and places every program, binds the witnesses, + /// sets the required values and locks the recursive outputs. Returns the required value the case violates, if any. + /// + /// # Errors + /// Returns a [`SetupError`] when the values do not fit a program or a binding panics. + pub(crate) fn build( + &self, + values: CaseValues, + signer: &Signer, + ) -> Result<(Vec, FinalTransaction, Option>), SetupError> { + let mut builder = CaseBuilder::new(self, signer, values.change)?; + + builder.place_programs(values.programs)?; + builder.bind_witnesses()?; + let violated = builder.require_witnesses()?; + builder.check_spending_paths()?; + builder.lock_recursive_outputs()?; + + Ok((builder.programs, builder.transaction, violated)) + } + + /// # Panics + /// Panics when no program is named `name`, or it is not a `P`. + pub(crate) fn program_scenario(&self, name: &str) -> (usize, &FuzzProgramScenario) { + let (index, program_scenario) = self + .program_scenarios + .iter() + .enumerate() + .find(|(_, program_scenario)| program_scenario.name == name) + .unwrap_or_else(|| panic!("No program is named `{name}`")); + + let (type_id, program_name) = program_scenario.program_type; + assert!( + type_id == TypeId::of::

(), + "`{name}` is a `{program_name}`, not a `{}`", + type_name::

() + ); + + (index, program_scenario) + } + + /// The program spending the input at `input` after the mutations, `None` for a fixed or added input. + pub(crate) fn program_scenario_by_input(&self, input: usize, map: &UtxoMap) -> Option<&FuzzProgramScenario> { + let template = map.old_input_index(input)?; + + self.program_scenarios + .iter() + .find(|program_scenario| program_scenario.spends(template)) + } + + /// Every output the lock check protects after the mutations, with the name of the program it is locked to. + pub(crate) fn locked_outputs(&self, map: &UtxoMap) -> Vec<(usize, &str)> { + self.program_scenarios + .iter() + .flat_map(|program_scenario| { + program_scenario + .outputs + .iter() + .filter(|index| !program_scenario.unchecked_outputs.contains(index)) + .map(|&index| (map.new_output_position(index), program_scenario.name.as_str())) + }) + .collect() + } + + /// Each program and the fixed program inputs with their bound values. + pub(crate) fn describe(&self, programs: &[Program], transaction: &FinalTransaction, map: &UtxoMap) -> String { + self.program_scenarios + .iter() + .zip(programs) + .map(|(program_scenario, program)| { + let mut description = format!("`{}` arguments: {}", program_scenario.name, program.get_arguments()); + + if let Some(witness) = program_scenario.witness_in(transaction, map) { + description += &format!(" and witness: {witness}"); + } + + if !program.get_storage().is_empty() { + let storage = program + .get_storage() + .iter() + .map(|slot| slot.iter().map(|byte| format!("{byte:02x}")).collect::()) + .collect::>() + .join(", "); + + description += &format!(" and storage: [{storage}]"); + } + + description + }) + .collect::>() + .join("; ") + } + + /// Where each program sits, e.g. "vault spends input 0, unvault locks output 0". + pub(crate) fn layout(&self) -> String { + let programs = self.program_scenarios.iter().map(|program_scenario| { + let roles = program_scenario + .inputs + .iter() + .map(|index| format!("spends input {index}")) + .chain(program_scenario.outputs.iter().map(|index| { + if program_scenario + .transitions + .iter() + .any(|(recursive, _)| recursive == index) + { + format!("locks output {index} recursively") + } else if program_scenario.unchecked_outputs.contains(index) { + format!("locks output {index} unchecked") + } else { + format!("locks output {index}") + } + })) + .collect::>() + .join(" and "); + + format!("{} {roles}", program_scenario.name) + }); + + let fixed = self + .transaction + .inputs() + .iter() + .enumerate() + .filter(|(index, input)| { + input.program_input.is_some() + && !self + .program_scenarios + .iter() + .any(|program_scenario| program_scenario.spends(*index)) + }) + .map(|(index, _)| format!("input {index} is fixed")); + + programs.chain(fixed).collect::>().join(", ") + } +} + +/// One case being built: the assembled transaction with the case's mutations, filled program by program. +struct CaseBuilder<'a, 's> { + scenario: &'a Scenario, + signer: &'s Signer, + change: CaseChange, + map: UtxoMap, + programs: Vec, + transaction: FinalTransaction, +} + +impl<'a, 's> CaseBuilder<'a, 's> { + fn new(scenario: &'a Scenario, signer: &'s Signer, change: CaseChange) -> Result { + let mut transaction = scenario.transaction.clone(); + + for mutation in change.mutations() { + mutation.apply(&mut transaction)?; + } + + Ok(Self { + scenario, + signer, + map: scenario.utxo_map(change.mutations()), + change, + programs: Vec::with_capacity(scenario.program_scenarios.len()), + transaction, + }) + } + + /// The case as built so far. + fn case(&self) -> FuzzCase<'_> { + FuzzCase::new( + self.scenario, + &self.programs, + &self.transaction, + self.change.mutations(), + self.signer, + ) + } + + /// Binds the arguments of every program in order, builds it and places it into the transaction. + fn place_programs(&mut self, values: Vec) -> Result<(), SetupError> { + let scenario = self.scenario; + + for (program_scenario, values) in scenario.program_scenarios.iter().zip(values) { + let FuzzProgramValues { + arguments, + witness, + storage, + } = values; + let name = &program_scenario.name; + + if storage.len() != program_scenario.storage_capacity { + return Err(SetupError::Storage(format!( + "`{name}` was generated {} slot(s), but it has {}", + storage.len(), + program_scenario.storage_capacity + ))); + } + + let arguments = + program_scenario.run_bindings(&program_scenario.argument_bindings, arguments, &self.case())?; + + let mut program = (program_scenario.build_program)(&arguments) + .map_err(|error| SetupError::Arguments(format!("`{name}`: {error}")))?; + program.set_storage(storage); + let script = program.get_script_pubkey(self.signer.get_network()); + + if !program_scenario.inputs.is_empty() { + (program_scenario.check_witness)(&witness) + .map_err(|error| SetupError::Witness(format!("`{name}`: {error}")))?; + } + + for index in program_scenario.inputs_in(&self.map) { + let input = &mut self.transaction.inputs_mut()[index]; + + input.partial_input.witness_utxo.script_pubkey = script.clone(); + input.program_input = Some(ProgramInput::new(Box::new(program.clone()), witness.clone())); + } + + for index in program_scenario.outputs_in(&self.map) { + self.transaction.outputs_mut()[index].script_pubkey = script.clone(); + } + + self.programs.push(program); + } + + Ok(()) + } + + /// Binds the witnesses once every program is built. + fn bind_witnesses(&mut self) -> Result<(), SetupError> { + let scenario = self.scenario; + + for program_scenario in &scenario.program_scenarios { + let Some(witness) = program_scenario.witness_in(&self.transaction, &self.map).cloned() else { + continue; + }; + + let witness = program_scenario.run_bindings(&program_scenario.witness_bindings, witness, &self.case())?; + + program_scenario.place_witness(&mut self.transaction, &self.map, witness); + } + + Ok(()) + } + + /// Sets the required witness values, then violates one of them when the case picks it. + fn require_witnesses(&mut self) -> Result>, SetupError> { + let scenario = self.scenario; + let mut requirements = Requirements::new(); + + for program_scenario in &scenario.program_scenarios { + let Some(generated) = program_scenario.witness_in(&self.transaction, &self.map).cloned() else { + continue; + }; + + let required = program_scenario.run_bindings( + &program_scenario.witness_requirements, + generated.clone(), + &self.case(), + )?; + + requirements.record_diff(program_scenario, &generated, &required); + program_scenario.place_witness(&mut self.transaction, &self.map, required); + } + + Ok(requirements.violate_one(self.change.violation(), &mut self.transaction, &self.map)) + } + + /// Checks that custom strategies, bindings and requirements don't leave the spending paths. + fn check_spending_paths(&self) -> Result<(), SetupError> { + for program_scenario in &self.scenario.program_scenarios { + let Some(witness) = program_scenario.witness_in(&self.transaction, &self.map) else { + continue; + }; + + for (field, path) in &program_scenario.spending_paths { + if !witness.get(field).is_some_and(|value| path.follows(value)) { + return Err(SetupError::Witness(format!( + "`{}` leaves its spending path {path} in `{field}`", + program_scenario.name + ))); + } + } + } + + Ok(()) + } + + /// Locks the recursive outputs to each program with its next storage. + fn lock_recursive_outputs(&mut self) -> Result<(), SetupError> { + let scenario = self.scenario; + + for (program_index, program_scenario) in scenario.program_scenarios.iter().enumerate() { + let name = &program_scenario.name; + + for (index, transition) in &program_scenario.transitions { + let storage = { + let case = self.case(); + + panic::catch_unwind(AssertUnwindSafe(|| { + let storage = transition(&case); + + assert!( + storage.len() == program_scenario.storage_capacity, + "The transition of `{name}` returned {} storage slot(s), but it has {}", + storage.len(), + program_scenario.storage_capacity + ); + + storage + })) + .map_err(|_| SetupError::Binding(name.clone()))? + }; + + let mut next = self.programs[program_index].clone(); + next.set_storage(storage); + + self.transaction.outputs_mut()[self.map.new_output_position(*index)].script_pubkey = + next.get_script_pubkey(self.signer.get_network()); + } + } + + Ok(()) + } +} + +impl Default for Scenario { + /// A transaction with one default input. + fn default() -> Self { + let mut transaction = FinalTransaction::new(); + transaction.add_input(PartialInput::default(), None); + + Self::new(transaction) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use proptest::strategy::{Just, Strategy}; + + use simplicityhl::elements::{OutPoint, Txid}; + use simplicityhl::types::TypeConstructible; + use simplicityhl::value::ValueConstructible; + use simplicityhl::{Arguments, ResolvedType, TemplateProgramWitness, Value, WitnessValues}; + + use smplx_sdk::transaction::{PartialOutput, UTXO}; + + use crate::fuzz::program_strategy::FuzzProgramStrategy; + use crate::fuzz::witness_path::WitnessPath; + + use super::*; + + fn program_scenario(name: &str, inputs: &[usize], outputs: &[usize]) -> FuzzProgramScenario { + FuzzProgramScenario { + name: name.to_string(), + inputs: inputs.to_vec(), + outputs: outputs.to_vec(), + unchecked_outputs: Vec::new(), + spending_paths: Vec::new(), + witness_types: Vec::new(), + transitions: Vec::new(), + storage_capacity: 0, + program_type: (TypeId::of::<()>(), "()"), + strategy: FuzzProgramStrategy::Other( + Just(FuzzProgramValues::from(( + Arguments::default(), + WitnessValues::default(), + ))) + .boxed(), + ), + argument_bindings: Vec::new(), + witness_bindings: Vec::new(), + witness_requirements: Vec::new(), + build_program: |_| unreachable!(), + check_witness: |_| unreachable!(), + } + } + + fn two_inputs_one_output() -> FinalTransaction { + let mut transaction = FinalTransaction::new(); + + for vout in 0..2 { + let utxo = UTXO { + outpoint: OutPoint::new(Txid::from_byte_array(Default::default()), vout), + ..UTXO::default() + }; + + transaction.add_input(PartialInput::new(utxo), None); + } + + transaction.add_output(PartialOutput::default()); + + transaction + } + + #[test] + fn invalid_scenarios_are_rejected() { + let cases = [ + ( + vec![program_scenario("a", &[2], &[])], + "`a` spends input 2, but the transaction has 2 input(s)", + ), + ( + vec![program_scenario("a", &[0], &[1])], + "`a` locks output 1, but the transaction has 1 output(s)", + ), + ( + vec![program_scenario("a", &[0], &[]), program_scenario("b", &[0], &[])], + "input 0 is spent by both `a` and `b`", + ), + ( + vec![program_scenario("a", &[0], &[0]), program_scenario("b", &[], &[0])], + "output 0 is locked by both `a` and `b`", + ), + (vec![program_scenario("a", &[], &[0])], "no program spends an input"), + ( + vec![program_scenario("a", &[0], &[]), program_scenario("a", &[1], &[])], + "two programs are named `a`", + ), + ( + { + let mut unknown_path = program_scenario("a", &[0], &[]); + unknown_path.spending_paths.push(( + TemplateProgramWitness::witness_from_str("PATH"), + WitnessPath::new(["Left"]), + )); + + vec![unknown_path] + }, + "`a` has no witness field `PATH` with the spending path", + ), + ( + { + let mut index_last = program_scenario("a", &[0], &[]); + index_last.spending_paths.push(( + TemplateProgramWitness::witness_from_str("PATH"), + WitnessPath::new(["Left", "0"]), + )); + + vec![index_last] + }, + "which must end at an `Either` branch", + ), + ]; + + for (program_scenarios, reason) in cases { + let mut scenario = Scenario::new(two_inputs_one_output()); + scenario.program_scenarios = program_scenarios; + + let error = scenario.validate().unwrap_err().to_string(); + + assert!(error.contains(reason), "`{error}` does not mention `{reason}`"); + } + } + + fn nested_witness(second: u32) -> WitnessValues { + let value = Value::right(ResolvedType::u32(), Value::tuple([Value::u32(1), Value::u32(second)])); + + WitnessValues::from_map(HashMap::from([( + TemplateProgramWitness::witness_from_str("PATH"), + value, + )])) + } + + #[test] + fn violates_a_nested_value() { + let program_scenario = program_scenario("program", &[0], &[]); + let program = Program::new("fn main() {}", Arguments::default()); + + let mut transaction = FinalTransaction::new(); + transaction.add_program_input( + PartialInput::default(), + ProgramInput::new(Box::new(program), nested_witness(5)), + None, + ); + + let map = UtxoMap::new(&transaction, &[]); + let mut requirements = Requirements::new(); + requirements.record_diff(&program_scenario, &nested_witness(2), &nested_witness(5)); + + let violated = requirements + .violate_one(Some(0), &mut transaction, &map) + .expect("one value is required"); + + assert_eq!( + violated.to_string(), + r#"`program` witness `PATH` at ["Right", "1"] is 2 instead of 5"# + ); + assert_eq!( + program_scenario.witness_in(&transaction, &map), + Some(&nested_witness(2)) + ); + } +} diff --git a/crates/test/src/fuzz/scenario_strategy/combined.rs b/crates/test/src/fuzz/scenario_strategy/combined.rs new file mode 100644 index 00000000..41e08f68 --- /dev/null +++ b/crates/test/src/fuzz/scenario_strategy/combined.rs @@ -0,0 +1,116 @@ +use std::borrow::Cow; + +use proptest::strategy::{NewTree, Strategy, ValueTree}; +use proptest::test_runner::{Reason, TestRng, TestRunner}; + +use rand::RngExt; + +use smplx_sdk::transaction::FinalTransaction; + +use crate::fuzz::common::FixedValueTree; +use crate::fuzz::mutations::{Mutations, Mutator}; +use crate::fuzz::outcome::SetupError; +use crate::fuzz::program_scenario::FuzzProgramScenario; +use crate::fuzz::program_strategy::{ + FuzzProgramSchema, FuzzProgramStrategy, FuzzProgramValues, GuidedCase, RandomCase, +}; +use crate::fuzz::scenario_strategy::FuzzTransactionSchema; +use crate::fuzz::scenario_strategy::{CaseChange, CaseValues}; + +const VIOLATION_PERCENT: u32 = 50; +const MUTATION_PERCENT: u32 = 50; + +/// Combines the strategies of every program of a case. Guided programs are generated together, so they share literals and values. +#[derive(Debug)] +pub(crate) struct CombinedStrategy { + strategies: Vec, + transaction: FuzzTransactionSchema, + requirements: bool, + mutator: Option, +} + +impl CombinedStrategy { + /// # Errors + /// Returns a [`SetupError`] when `mutations` cannot mutate `transaction`. + pub(crate) fn new( + program_scenarios: &[FuzzProgramScenario], + transaction: &FinalTransaction, + mutations: Option<&Mutations>, + ) -> Result { + let mutator = mutations + .map(|mutations| Mutator::new(mutations.clone(), transaction, program_scenarios)) + .transpose()?; + + Ok(Self { + strategies: program_scenarios + .iter() + .map(FuzzProgramScenario::configured_strategy) + .collect(), + transaction: FuzzTransactionSchema::of(transaction), + requirements: program_scenarios.iter().any(FuzzProgramScenario::has_requirements), + mutator, + }) + } + + /// The case's change, with the transaction schema to generate program values against. + fn draw_change(&self, rng: &mut TestRng) -> (CaseChange, Cow<'_, FuzzTransactionSchema>) { + if self.requirements && rng.random_range(0..100) < VIOLATION_PERCENT { + return (CaseChange::Violation(rng.random()), Cow::Borrowed(&self.transaction)); + } + + match &self.mutator { + Some(mutator) if rng.random_range(0..100) < MUTATION_PERCENT => { + let (mutations, transaction) = mutator.generate(rng); + + ( + CaseChange::Mutations(mutations), + Cow::Owned(FuzzTransactionSchema::of(&transaction)), + ) + } + _ => (CaseChange::None, Cow::Borrowed(&self.transaction)), + } + } + + fn generate_programs( + &self, + runner: &mut TestRunner, + transaction: &FuzzTransactionSchema, + ) -> Result, Reason> { + let schemas: Vec<&FuzzProgramSchema> = self + .strategies + .iter() + .filter_map(|strategy| match strategy { + FuzzProgramStrategy::Guided(schema) => Some(schema), + FuzzProgramStrategy::Random(_) | FuzzProgramStrategy::Other(_) => None, + }) + .collect(); + + let mut guided = GuidedCase::new(&schemas, runner.rng()) + .with_transaction_schema(transaction) + .generate() + .into_iter(); + let mut values = Vec::with_capacity(self.strategies.len()); + + for strategy in &self.strategies { + values.push(match strategy { + FuzzProgramStrategy::Guided(_) => guided.next().expect("one value per guided program"), + FuzzProgramStrategy::Random(schema) => RandomCase::new(schema, runner.rng()).generate(), + FuzzProgramStrategy::Other(strategy) => strategy.new_tree(runner)?.current(), + }); + } + + Ok(values) + } +} + +impl Strategy for CombinedStrategy { + type Tree = FixedValueTree; + type Value = CaseValues; + + fn new_tree(&self, runner: &mut TestRunner) -> NewTree { + let (change, transaction) = self.draw_change(runner.rng()); + let programs = self.generate_programs(runner, &transaction)?; + + Ok(FixedValueTree(CaseValues { programs, change })) + } +} diff --git a/crates/test/src/fuzz/scenario_strategy/core.rs b/crates/test/src/fuzz/scenario_strategy/core.rs new file mode 100644 index 00000000..e328685b --- /dev/null +++ b/crates/test/src/fuzz/scenario_strategy/core.rs @@ -0,0 +1,33 @@ +use crate::fuzz::mutations::Mutation; +use crate::fuzz::program_strategy::FuzzProgramValues; + +/// The values of one case. +#[derive(Clone, Debug)] +pub(crate) struct CaseValues { + pub(crate) programs: Vec, + pub(crate) change: CaseChange, +} + +/// What a case changes besides program values. +#[derive(Clone, Debug)] +pub(crate) enum CaseChange { + None, + Violation(u64), + Mutations(Vec), +} + +impl CaseChange { + pub(crate) fn violation(&self) -> Option { + match self { + Self::Violation(pick) => Some(*pick), + Self::None | Self::Mutations(_) => None, + } + } + + pub(crate) fn mutations(&self) -> &[Mutation] { + match self { + Self::Mutations(mutations) => mutations, + Self::None | Self::Violation(_) => &[], + } + } +} diff --git a/crates/test/src/fuzz/scenario_strategy/mod.rs b/crates/test/src/fuzz/scenario_strategy/mod.rs new file mode 100644 index 00000000..dd93fe59 --- /dev/null +++ b/crates/test/src/fuzz/scenario_strategy/mod.rs @@ -0,0 +1,7 @@ +mod combined; +mod core; +mod transaction_schema; + +pub(crate) use combined::*; +pub(crate) use core::*; +pub(crate) use transaction_schema::FuzzTransactionSchema; diff --git a/crates/test/src/fuzz/scenario_strategy/transaction_schema.rs b/crates/test/src/fuzz/scenario_strategy/transaction_schema.rs new file mode 100644 index 00000000..60a02579 --- /dev/null +++ b/crates/test/src/fuzz/scenario_strategy/transaction_schema.rs @@ -0,0 +1,48 @@ +use simplicityhl::num::U256; + +use smplx_sdk::transaction::FinalTransaction; +use smplx_sdk::utils::hash_script; + +/// What guided generation draws from the scenario's transaction. +#[derive(Clone, Debug)] +pub(crate) struct FuzzTransactionSchema { + /// Amounts, assets and script hashes of the inputs and outputs. + pub(crate) literals: Vec, + /// The larger of the input and output counts. + pub(crate) utxos_max: u32, +} + +impl FuzzTransactionSchema { + pub(crate) fn of(transaction: &FinalTransaction) -> Self { + let inputs = transaction.inputs().iter().map(|input| { + let input = &input.partial_input; + let known = input.asset_and_amount(); + + ( + known.map(|(_, amount)| amount), + known.map(|(asset, _)| asset), + &input.witness_utxo.script_pubkey, + ) + }); + let outputs = transaction + .outputs() + .iter() + .map(|output| (Some(output.amount), Some(output.asset), &output.script_pubkey)); + + let mut literals = Vec::new(); + + for (amount, asset, script) in inputs.chain(outputs) { + literals.extend(amount.map(U256::from)); + literals.extend(asset.map(|asset| U256::from_byte_array(asset.to_byte_array()))); + + if !script.is_empty() { + literals.push(U256::from_byte_array(hash_script(script))); + } + } + + Self { + literals, + utxos_max: u32::try_from(transaction.n_inputs().max(transaction.n_outputs())).unwrap_or(u32::MAX), + } + } +} diff --git a/crates/test/src/fuzz/smart_check/auth.rs b/crates/test/src/fuzz/smart_check/auth.rs new file mode 100644 index 00000000..7de5d860 --- /dev/null +++ b/crates/test/src/fuzz/smart_check/auth.rs @@ -0,0 +1,244 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use simplicityhl::num::U256; +use simplicityhl::value::ValueConstructible; +use simplicityhl::{Value, WitnessNameToValueMap, WitnessValues}; + +use smplx_sdk::signer::Signer; +use smplx_sdk::transaction::{FinalTransaction, RequiredSignature, SigMessage}; + +use crate::fuzz::execution::SignedTransaction; +use crate::fuzz::outcome::SetupError; + +/// Checks that an accepted spend depends on its signatures. +pub(crate) struct AuthCheck<'a> { + signer: &'a Signer, + attacker: &'a Signer, + transaction: &'a FinalTransaction, + signed_inputs: Vec<(usize, &'a str)>, +} + +impl<'a> AuthCheck<'a> { + /// `None` when no program input requests a signature. Each signed input comes with its signature's witness name. + pub(crate) fn new(signer: &'a Signer, attacker: &'a Signer, transaction: &'a FinalTransaction) -> Option { + let signed_inputs: Vec<(usize, &str)> = transaction + .inputs() + .iter() + .enumerate() + .filter(|(_, input)| input.program_input.is_some()) + .filter_map(|(index, input)| match &input.required_sig { + RequiredSignature::Program(signature) => Some((index, signature.name())), + RequiredSignature::None | RequiredSignature::NativeEcdsa => None, + }) + .collect(); + + (!signed_inputs.is_empty()).then_some(Self { + signer, + attacker, + transaction, + signed_inputs, + }) + } + + /// The bypasses found, empty when every variant is rejected. + /// + /// # Errors + /// Returns a [`SetupError`] when a variant cannot be signed or executed. + pub(crate) fn run(&self) -> Result, SetupError> { + let wrong_key = self.accepted_inputs(self.attacker, &self.with_attacker_key())?; + let replayed = self.accepted_inputs(self.signer, &self.replayed())?; + + let mut bypasses = Vec::new(); + + for &(index, name) in &self.signed_inputs { + let bypass = match (wrong_key.contains(&index), replayed.contains(&index)) { + (true, true) => format!("input {index} accepts any `{name}`, the signature is not checked"), + (true, false) => format!("input {index} accepts a `{name}` signature from any key"), + (false, true) => format!( + "input {index} accepts a `{name}` signature made for another transaction, so it can be replayed" + ), + (false, false) => continue, + }; + + bypasses.push(bypass); + } + + Ok(bypasses) + } + + /// The attacker also swaps any key the contract reads from the witness. + fn with_attacker_key(&self) -> FinalTransaction { + let own_key = Self::key_value(self.signer); + let attacker_key = Self::key_value(self.attacker); + + let mut transaction = self.transaction.clone(); + + for program_input in transaction + .inputs_mut() + .iter_mut() + .filter_map(|input| input.program_input.as_mut()) + { + let witness: HashMap<_, _> = program_input + .witness + .iter() + .map(|(name, value)| { + let value = if *value == own_key { + attacker_key.clone() + } else { + value.clone() + }; + + (name.clone(), value) + }) + .collect(); + + program_input.witness = WitnessValues::from_map(witness); + } + + transaction + } + + /// Each signed input signs what it would sign for a transaction with a different sighash. + fn replayed(&self) -> FinalTransaction { + let mut transaction = self.transaction.clone(); + + for &(index, _) in &self.signed_inputs { + let input = &mut transaction.inputs_mut()[index]; + + let RequiredSignature::Program(signature) = &input.required_sig else { + continue; + }; + + let message = signature.message().clone(); + let other_message = SigMessage::Custom(Arc::new(move |sighash: [u8; 32]| { + message.digest(sighash.map(|byte| !byte)) + })); + + input.required_sig = RequiredSignature::Program(signature.clone().with_message(other_message)); + } + + transaction + } + + fn accepted_inputs(&self, signer: &Signer, transaction: &FinalTransaction) -> Result, SetupError> { + let signed = SignedTransaction::new(signer, transaction)?; + let mut accepted = Vec::new(); + + for &(index, _) in &self.signed_inputs { + if signed.run(index)?.is_accepted() { + accepted.push(index); + } + } + + Ok(accepted) + } + + fn key_value(signer: &Signer) -> Value { + Value::u256(U256::from_byte_array(signer.get_schnorr_public_key().serialize())) + } +} + +#[cfg(test)] +mod tests { + use simplicityhl::{Arguments, TemplateProgramWitness}; + + use smplx_sdk::program::Program; + use smplx_sdk::provider::SimplicityNetwork; + use smplx_sdk::transaction::{PartialInput, ProgramInput, ProgramSignature, UTXO}; + + use crate::config::DEFAULT_TEST_MNEMONIC; + use crate::fuzz::core::ATTACKER_MNEMONIC; + + use super::*; + + struct Contract { + source: &'static str, + key_argument: bool, + key_witness: bool, + required_sig: Option, + } + + impl Contract { + fn bypasses(self) -> Vec { + let signer = Signer::from_mnemonic(DEFAULT_TEST_MNEMONIC, SimplicityNetwork::default_regtest()); + let attacker = Signer::from_mnemonic(ATTACKER_MNEMONIC, SimplicityNetwork::default_regtest()); + let key = AuthCheck::key_value(&signer); + + let arguments = if self.key_argument { + Arguments::from_map(HashMap::from([( + TemplateProgramWitness::parameter_from_str("KEY"), + key.clone(), + )])) + } else { + Arguments::default() + }; + let witness = if self.key_witness { + WitnessValues::from_map(HashMap::from([(TemplateProgramWitness::witness_from_str("KEY"), key)])) + } else { + WitnessValues::default() + }; + + let program = Program::new(self.source, arguments); + + let mut utxo = UTXO::default(); + utxo.txout.script_pubkey = program.get_script_pubkey(signer.get_network()); + + let mut transaction = FinalTransaction::new(); + transaction.add_program_input( + PartialInput::new(utxo), + ProgramInput::new(Box::new(program), witness), + self.required_sig, + ); + + let check = AuthCheck::new(&signer, &attacker, &transaction).unwrap(); + + assert_eq!( + check.accepted_inputs(&signer, &transaction).unwrap(), + vec![0], + "the honest spend must be accepted" + ); + + check.run().unwrap() + } + } + + #[test] + fn finds_each_kind_of_bypass() { + let signature = || Some(ProgramSignature::new("SIGNATURE")); + + let p2pk = Contract { + source: "fn main() { jet::bip_0340_verify((param::KEY, jet::sig_all_hash()), witness::SIGNATURE) }", + key_argument: true, + key_witness: false, + required_sig: signature(), + }; + assert!(p2pk.bypasses().is_empty()); + + let unchecked = Contract { + source: "fn main() { let sig: Signature = witness::SIGNATURE; }", + key_argument: false, + key_witness: false, + required_sig: signature(), + }; + assert!(unchecked.bypasses()[0].contains("the signature is not checked")); + + let key_from_witness = Contract { + source: "fn main() { jet::bip_0340_verify((witness::KEY, jet::sig_all_hash()), witness::SIGNATURE) }", + key_argument: false, + key_witness: true, + required_sig: signature(), + }; + assert!(key_from_witness.bypasses()[0].contains("from any key")); + + let constant_message = Contract { + source: "fn main() { jet::bip_0340_verify((param::KEY, 0x0000000000000000000000000000000000000000000000000000000000000000), witness::SIGNATURE) }", + key_argument: true, + key_witness: false, + required_sig: Some( + ProgramSignature::new("SIGNATURE").with_message(SigMessage::Custom(Arc::new(|_| [0; 32]))), + ), + }; + assert!(constant_message.bypasses()[0].contains("can be replayed")); + } +} diff --git a/crates/test/src/fuzz/smart_check/lock.rs b/crates/test/src/fuzz/smart_check/lock.rs new file mode 100644 index 00000000..0a8d3048 --- /dev/null +++ b/crates/test/src/fuzz/smart_check/lock.rs @@ -0,0 +1,61 @@ +use simplicityhl::elements::Script; + +use smplx_sdk::signer::Signer; +use smplx_sdk::transaction::FinalTransaction; + +use crate::fuzz::execution::SignedTransaction; +use crate::fuzz::outcome::SetupError; + +/// Checks that an accepted spend depends on the outputs it locks. +pub(crate) struct LockCheck<'a> { + signer: &'a Signer, + attacker: &'a Signer, + transaction: &'a FinalTransaction, + locked_outputs: Vec<(usize, &'a str)>, +} + +impl<'a> LockCheck<'a> { + /// `None` when no output is locked. Each locked output comes with the program it is locked to. + pub(crate) fn new( + signer: &'a Signer, + attacker: &'a Signer, + transaction: &'a FinalTransaction, + locked_outputs: Vec<(usize, &'a str)>, + ) -> Option { + (!locked_outputs.is_empty()).then_some(Self { + signer, + attacker, + transaction, + locked_outputs, + }) + } + + /// The bypasses found, empty when every redirected output is rejected. + /// + /// # Errors + /// Returns a [`SetupError`] when a variant cannot be signed or executed. + pub(crate) fn run(&self) -> Result, SetupError> { + let attacker_script = self.attacker.get_address().script_pubkey(); + let mut bypasses = Vec::new(); + + for &(index, name) in &self.locked_outputs { + if self.accepts(&self.redirected(index, &attacker_script))? { + bypasses.push(format!("output {index} (locked to `{name}`) can be redirected")); + } + } + + Ok(bypasses) + } + + fn redirected(&self, index: usize, script: &Script) -> FinalTransaction { + let mut transaction = self.transaction.clone(); + transaction.outputs_mut()[index].script_pubkey = script.clone(); + + transaction + } + + /// Signatures are redone, so a valid signature cannot hide a missing output check. + fn accepts(&self, transaction: &FinalTransaction) -> Result { + SignedTransaction::new(self.signer, transaction)?.accepts_all() + } +} diff --git a/crates/test/src/fuzz/smart_check/mod.rs b/crates/test/src/fuzz/smart_check/mod.rs new file mode 100644 index 00000000..c7699a1e --- /dev/null +++ b/crates/test/src/fuzz/smart_check/mod.rs @@ -0,0 +1,5 @@ +mod auth; +mod lock; + +pub(crate) use auth::AuthCheck; +pub(crate) use lock::LockCheck; diff --git a/crates/test/src/fuzz/summary.rs b/crates/test/src/fuzz/summary.rs new file mode 100644 index 00000000..497e99a6 --- /dev/null +++ b/crates/test/src/fuzz/summary.rs @@ -0,0 +1,230 @@ +use std::collections::HashMap; +use std::fmt::{Display, Formatter, Result as FmtResult}; + +use proptest::test_runner::RngSeed; + +use crate::fuzz::case::Expect; +use crate::fuzz::mutations::Mutation; +use crate::fuzz::outcome::{Outcome, Rejection}; + +/// A fuzz run summary, printed when the run ends. +pub(crate) struct RunSummary { + test_name: &'static str, + seed: RngSeed, + layout: String, + cases: usize, + accepted: usize, + rejections: HashMap, + rejected_by: Vec<(String, usize)>, + expectations: HashMap, + violations: usize, + unviolated: usize, + check_lock: bool, + lock_checks: usize, + check_auth: bool, + auth_checks: usize, + mutate: bool, + mutated: usize, + mutation_kinds: Vec<(&'static str, usize)>, +} + +impl RunSummary { + pub(crate) fn new( + test_name: &'static str, + seed: RngSeed, + layout: String, + check_lock: bool, + check_auth: bool, + mutate: bool, + ) -> Self { + Self { + test_name, + seed, + layout, + cases: 0, + accepted: 0, + rejections: HashMap::new(), + rejected_by: Vec::new(), + expectations: HashMap::new(), + violations: 0, + unviolated: 0, + check_lock, + lock_checks: 0, + check_auth, + auth_checks: 0, + mutate, + mutated: 0, + mutation_kinds: Vec::new(), + } + } + + /// `expect` is `None` for a case that violates a requirement. + pub(crate) fn record(&mut self, expect: Option, verdict: Outcome, rejected_by: Option) { + self.cases += 1; + + match expect { + Some(expect) => *self.expectations.entry(expect).or_default() += 1, + None if !verdict.is_accepted() => self.violations += 1, + None => {} + } + + match verdict { + Outcome::Accepted => self.accepted += 1, + Outcome::Rejected(rejection) => *self.rejections.entry(rejection).or_default() += 1, + } + + if let Some(name) = rejected_by { + match self.rejected_by.iter_mut().find(|(rejector, _)| *rejector == name) { + Some((_, count)) => *count += 1, + None => self.rejected_by.push((name, 1)), + } + } + } + + pub(crate) fn record_mutations(&mut self, mutations: &[Mutation]) { + if !mutations.is_empty() { + self.mutated += 1; + } + + for mutation in mutations { + match self + .mutation_kinds + .iter_mut() + .find(|(kind, _)| *kind == mutation.kind()) + { + Some((_, count)) => *count += 1, + None => self.mutation_kinds.push((mutation.kind(), 1)), + } + } + } + + /// A case drew a violation, but none of its required values differs from the generated one. + pub(crate) fn record_unviolated(&mut self) { + self.unviolated += 1; + } + + pub(crate) fn record_lock_check(&mut self) { + self.lock_checks += 1; + } + + pub(crate) fn record_auth_check(&mut self) { + self.auth_checks += 1; + } + + pub(crate) fn is_run_empty(&self, allow_unspendable: bool) -> Option { + if self.expectations.get(&Expect::Any) == Some(&(self.cases - self.violations)) { + return Some(format!( + "The spec made no prediction in {} case(s), it returned `Expect::Any` for every run", + self.cases + )); + } + + if self.accepted == 0 && !allow_unspendable { + return Some(format!( + "No case was accepted in {} run(s), so either the spec is faulty or the covenant is unspendable. \ + Consider calling `allow_unspendable()` if unspendability is intended", + self.cases + )); + } + + None + } + + fn count(counts: &HashMap, key: &K) -> usize { + counts.get(key).copied().unwrap_or_default() + } +} + +impl Display for RunSummary { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + let seed = match self.seed { + RngSeed::Fixed(seed) => format!("{seed:#018x}"), + RngSeed::Random => "random".to_string(), + }; + + let rejections = Rejection::ALL + .iter() + .map(|rejection| format!("{rejection} {}", Self::count(&self.rejections, rejection))) + .collect::>() + .join(", "); + + writeln!(f, "fuzz {}: {} case(s), seed {seed}", self.test_name, self.cases)?; + writeln!(f, " programs {}", self.layout)?; + writeln!( + f, + " verdicts accepted {}, rejected {} ({rejections})", + self.accepted, + self.cases - self.accepted + )?; + + if !self.rejected_by.is_empty() { + let rejected_by = self + .rejected_by + .iter() + .map(|(name, count)| format!("{name} {count}")) + .collect::>() + .join(", "); + + writeln!(f, " rejected by {rejected_by}")?; + } + + writeln!( + f, + " spec accept {}, reject {}, any {}", + Self::count(&self.expectations, &Expect::Accept), + Self::count(&self.expectations, &Expect::Reject), + Self::count(&self.expectations, &Expect::Any) + )?; + + if self.violations > 0 || self.unviolated > 0 { + write!( + f, + " requirements {} case(s) rejected with a required value violated", + self.violations + )?; + + if self.unviolated > 0 { + write!( + f, + ", {} drew the same violation as the required value and ran plain", + self.unviolated + )?; + } + + writeln!(f)?; + } + + if self.mutate { + let kinds = self + .mutation_kinds + .iter() + .map(|(kind, count)| format!("{kind} {count}")) + .collect::>() + .join(", "); + + writeln!(f, " mutations {} case(s) mutated ({kinds})", self.mutated)?; + } else { + writeln!(f, " mutations off")?; + } + + if self.check_lock { + writeln!( + f, + " lock {} accepted case(s) checked with their locked outputs redirected", + self.lock_checks + )?; + } else { + writeln!(f, " lock off")?; + } + + if self.check_auth { + write!( + f, + " authorization {} signed cases checked with a wrong key and a replayed signature", + self.auth_checks + ) + } else { + write!(f, " authorization off") + } + } +} diff --git a/crates/test/src/fuzz/utils.rs b/crates/test/src/fuzz/utils.rs index 3d5f73e1..8a9d1e86 100644 --- a/crates/test/src/fuzz/utils.rs +++ b/crates/test/src/fuzz/utils.rs @@ -17,6 +17,16 @@ pub fn random_witness(rng: &mut R) -> Witness WitnessValues::from_map(random_fields(&P::witness_types(), rng)) } +pub(crate) fn random_fields( + types: &[(TemplateProgramWitness, ResolvedType)], + rng: &mut R, +) -> HashMap { + types + .iter() + .map(|(name, ty)| (name.clone(), generate_random_value_by_ty(ty, rng))) + .collect() +} + pub fn generate_random_value_by_ty(ty: &ResolvedType, rng: &mut R) -> Value { match ty.as_inner() { TypeInner::Either(left_ty, right_ty) => match rng.random::() { @@ -55,13 +65,3 @@ pub fn generate_random_value_by_ty(ty: &ResolvedType, rng: &mut _ => Value::unit(), } } - -fn random_fields( - types: &[(TemplateProgramWitness, ResolvedType)], - rng: &mut R, -) -> HashMap { - types - .iter() - .map(|(name, ty)| (name.clone(), generate_random_value_by_ty(ty, rng))) - .collect() -} diff --git a/crates/test/src/fuzz/witness_path.rs b/crates/test/src/fuzz/witness_path.rs new file mode 100644 index 00000000..26ea3d2d --- /dev/null +++ b/crates/test/src/fuzz/witness_path.rs @@ -0,0 +1,213 @@ +use std::fmt::{Display, Formatter, Result as FmtResult}; + +use simplicityhl::either::Either; +use simplicityhl::types::TypeInner; +use simplicityhl::value::{ValueConstructible, ValueInner}; +use simplicityhl::{ResolvedType, Value}; + +/// A path into a witness value in the SDK's format, e.g. `["Right", "Left", "0"]`: +/// `Either` branches, tuple or array indices. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct WitnessPath(Vec); + +static EMPTY: WitnessPath = WitnessPath(Vec::new()); + +impl WitnessPath { + pub(crate) fn new(routes: I) -> Self + where + I: IntoIterator, + I::Item: AsRef, + { + Self(routes.into_iter().map(|route| route.as_ref().to_string()).collect()) + } + + pub(crate) fn empty() -> &'static Self { + &EMPTY + } + + pub(crate) fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub(crate) fn join(&self, route: impl Into) -> Self { + let mut routes = self.0.clone(); + routes.push(route.into()); + + Self(routes) + } + + /// Whether the path ends at an `Either` branch. + pub(crate) fn ends_at_branch(&self) -> bool { + matches!(self.0.last().map(String::as_str), Some("Left" | "Right")) + } + + /// Whether every route exists in `ty`. + pub(crate) fn fits(&self, ty: &ResolvedType) -> bool { + Self::fits_routes(ty, &self.0) + } + + /// Whether `value` takes the branches the path names. + pub(crate) fn follows(&self, value: &Value) -> bool { + Self::follows_routes(value, &self.0) + } + + /// Draws a value of `ty` that takes the branches the path names, drawing everything off the path with `draw`. + pub(crate) fn draw(&self, ty: &ResolvedType, draw: &mut impl FnMut(&ResolvedType) -> Value) -> Value { + Self::draw_routes(ty, &self.0, draw) + } + + /// `value` with the value at the path replaced by `new`. + /// + /// # Panics + /// Panics when `value` does not take the branches the path names. + pub(crate) fn replace(&self, value: &Value, new: Value) -> Value { + Self::replace_routes(value, &self.0, new) + } + + fn fits_routes(ty: &ResolvedType, routes: &[String]) -> bool { + let Some((route, routes)) = routes.split_first() else { + return true; + }; + + match (route.as_str(), ty.as_inner()) { + ("Left", TypeInner::Either(left, _)) => Self::fits_routes(left, routes), + ("Right", TypeInner::Either(_, right)) => Self::fits_routes(right, routes), + (index, TypeInner::Tuple(elements)) => index + .parse::() + .ok() + .and_then(|index| elements.get(index)) + .is_some_and(|element| Self::fits_routes(element, routes)), + (index, TypeInner::Array(element, size)) => { + index.parse::().is_ok_and(|index| index < *size) && Self::fits_routes(element, routes) + } + _ => false, + } + } + + fn follows_routes(value: &Value, routes: &[String]) -> bool { + let Some((route, routes)) = routes.split_first() else { + return true; + }; + + match (route.as_str(), value.inner()) { + ("Left", ValueInner::Either(Either::Left(inner))) | ("Right", ValueInner::Either(Either::Right(inner))) => { + Self::follows_routes(inner, routes) + } + (index, ValueInner::Tuple(elements) | ValueInner::Array(elements)) => index + .parse::() + .ok() + .and_then(|index| elements.get(index)) + .is_some_and(|element| Self::follows_routes(element, routes)), + _ => false, + } + } + + fn draw_routes(ty: &ResolvedType, routes: &[String], draw: &mut impl FnMut(&ResolvedType) -> Value) -> Value { + let Some((route, routes)) = routes.split_first() else { + return draw(ty); + }; + + match (route.as_str(), ty.as_inner()) { + ("Left", TypeInner::Either(left, right)) => { + Value::left(Self::draw_routes(left, routes, draw), (**right).clone()) + } + ("Right", TypeInner::Either(left, right)) => { + Value::right((**left).clone(), Self::draw_routes(right, routes, draw)) + } + (index, TypeInner::Tuple(elements)) => match index.parse::() { + Ok(index) if index < elements.len() => Value::tuple( + elements + .iter() + .enumerate() + .map(|(at, element)| { + if at == index { + Self::draw_routes(element, routes, draw) + } else { + draw(element) + } + }) + .collect::>(), + ), + _ => draw(ty), + }, + (index, TypeInner::Array(element, size)) => match index.parse::() { + Ok(index) if index < *size => Value::array( + (0..*size) + .map(|at| { + if at == index { + Self::draw_routes(element, routes, draw) + } else { + draw(element) + } + }) + .collect::>(), + (**element).clone(), + ), + _ => draw(ty), + }, + _ => draw(ty), + } + } + + fn replace_routes(value: &Value, routes: &[String], new: Value) -> Value { + let Some((route, rest)) = routes.split_first() else { + return new; + }; + + match (route.as_str(), value.inner(), value.ty().as_inner()) { + ("Left", ValueInner::Either(Either::Left(inner)), TypeInner::Either(_, right)) => { + Value::left(Self::replace_routes(inner, rest, new), (**right).clone()) + } + ("Right", ValueInner::Either(Either::Right(inner)), TypeInner::Either(left, _)) => { + Value::right((**left).clone(), Self::replace_routes(inner, rest, new)) + } + (index, ValueInner::Tuple(elements), _) => { + let index = index.parse::().expect("a tuple route is an index"); + + Value::tuple(elements.iter().enumerate().map(|(at, element)| { + if at == index { + Self::replace_routes(element, rest, new.clone()) + } else { + element.clone() + } + })) + } + _ => panic!("{value} does not take the path {routes:?}"), + } + } +} + +impl Display for WitnessPath { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + write!(f, "{:?}", self.0) + } +} + +#[cfg(test)] +mod tests { + use simplicityhl::types::TypeConstructible; + + use crate::fuzz::utils::generate_random_value_by_ty; + + use super::*; + + #[test] + fn draws_along_the_path() { + let ty = ResolvedType::either( + ResolvedType::u32(), + ResolvedType::either( + ResolvedType::tuple([ResolvedType::u256(), ResolvedType::u32()]), + ResolvedType::u32(), + ), + ); + let path = WitnessPath::new(["Right", "Left", "0"]); + let mut rng = rand::rng(); + + for _ in 0..64 { + let value = path.draw(&ty, &mut |ty| generate_random_value_by_ty(ty, &mut rng)); + + assert!(path.follows(&value), "{value} is not on the path"); + assert!(value.is_of_type(&ty)); + } + } +} diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml index a19f0114..f26b3b55 100644 --- a/crates/wasm/Cargo.toml +++ b/crates/wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smplx-wasm" -version = "0.1.1" +version = "0.2.0" description = "WebAssembly bindings for the Simplex SDK" license.workspace = true edition.workspace = true diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index c621e892..0fad1f09 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -24,7 +24,8 @@ use smplx_sdk::provider::SimplicityNetwork; use smplx_sdk::signer::Signer; use smplx_sdk::transaction::partial_input::IssuanceInput; use smplx_sdk::transaction::{ - ChangeOutput, FinalTransaction, IssuanceDetails, PartialInput, PartialOutput, ProgramInput, RequiredSignature, UTXO, + ChangeOutput, FinalTransaction, IssuanceDetails, NativeSignature, PartialInput, PartialOutput, ProgramInput, + ProgramSignature, UTXO, }; use wasm_bindgen::prelude::*; @@ -242,6 +243,14 @@ impl Covenant { fn state_leaves(json: &str) -> Result>, String> { let declared: Vec = serde_json::from_str(json).map_err(|e| format!("Invalid extra leaves: {e}"))?; + + if declared.len() > Program::MAX_STORAGE_SLOTS { + return Err(format!( + "A covenant holds at most {} extra leaves, and this one has {}", + Program::MAX_STORAGE_SLOTS, + declared.len() + )); + } let mut leaves = Vec::with_capacity(declared.len()); for (index, leaf) in declared.iter().enumerate() { @@ -417,6 +426,7 @@ impl TransactionBuilder { } /// Sets the block height this transaction may not be mined before. + /// Unless a sequence is set, the inputs get one that enforces it. /// /// # Panics /// Panics if `height` is `500_000_000` or greater, which Elements reads as a time. @@ -426,6 +436,7 @@ impl TransactionBuilder { } /// Sets the block time this transaction may not be mined before. + /// Unless a sequence is set, the inputs get one that enforces it. /// /// # Panics /// Panics if `time` is below `500_000_000`, which Elements reads as a height. @@ -434,7 +445,7 @@ impl TransactionBuilder { self.transaction.set_locktime(LockTime::from_time(time).unwrap()); } - /// Sets the sequence of for this transaction. + /// Sets the sequence of the inputs that don't declare their own. #[wasm_bindgen(js_name = setSequence)] pub fn set_sequence(&mut self, sequence: u32) { self.transaction.set_sequence(Sequence::from_consensus(sequence)); @@ -502,7 +513,7 @@ impl TransactionBuilder { derivation_path.as_deref(), )?; - self.transaction.add_input(input, RequiredSignature::NativeEcdsa); + self.transaction.add_input(input, Some(NativeSignature::Ecdsa)); Ok(()) } @@ -511,7 +522,7 @@ impl TransactionBuilder { /// /// # Errors /// Returns an error if the txid, the encoded output or the issuer contract cannot be parsed, - /// or if either issuance amount exceeds `i64::MAX`. + /// if either issuance amount exceeds `i64::MAX`, or if both are zero. #[wasm_bindgen(js_name = addWalletIssuanceInput)] #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] pub fn add_wallet_issuance_input( @@ -525,12 +536,7 @@ impl TransactionBuilder { blinding_secrets_json: Option, derivation_path: Option, ) -> Result { - if i64::try_from(asset_amount_sats).is_err() { - return Err(JsError::new("asset issuance amount must not exceed i64::MAX")); - } - if i64::try_from(inflation_amount_sats).is_err() { - return Err(JsError::new("inflation amount must not exceed i64::MAX")); - } + Self::check_issuance_amounts(asset_amount_sats, inflation_amount_sats)?; let contract = Self::issuer_contract(issuer_contract_hex.as_deref()) .map_err(|e| JsError::new(&format!("Invalid issuer contract: {e}")))?; @@ -546,7 +552,7 @@ impl TransactionBuilder { let details = self.transaction.add_issuance_input( input, IssuanceInput::new_issuance(asset_amount_sats, inflation_amount_sats, contract), - RequiredSignature::NativeEcdsa, + Some(NativeSignature::Ecdsa), ); Ok(IssuanceReport::from_details(&details)) @@ -570,7 +576,7 @@ impl TransactionBuilder { /// /// # Errors /// Returns an error for invalid input data, an amount outside 1..=`i64::MAX`, - /// or a claimed token asset that does not match the entropy. + /// a claimed token asset that does not match the entropy, or a token with a zero asset blinding factor. #[wasm_bindgen(js_name = addWalletReissuanceInput)] #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] pub fn add_wallet_reissuance_input( @@ -608,12 +614,17 @@ impl TransactionBuilder { secrets.asset ))); } + if secrets.asset_bf == AssetBlindingFactor::zero() { + return Err(JsError::new( + "the token's asset blinding factor is zero, which Elements reads as a new issuance of another asset", + )); + } let reissuance = IssuanceInput::new_reissuance(asset_amount_sats, entropy.to_byte_array()); let details = self .transaction - .add_issuance_input(input, reissuance, RequiredSignature::NativeEcdsa); + .add_issuance_input(input, reissuance, Some(NativeSignature::Ecdsa)); Ok(IssuanceReport::from_details(&details)) } @@ -657,7 +668,7 @@ impl TransactionBuilder { extra_leaves_json, include_debug_symbols, )?, - Self::required_signature(signature_witness.as_deref()), + Self::program_signature(signature_witness.as_deref()), ); Ok(()) @@ -670,8 +681,8 @@ impl TransactionBuilder { /// /// # Errors /// Returns an error if the txid, the encoded output, the arguments, the witness, the - /// issuer contract or the derivation path cannot be parsed, or if either issuance amount - /// exceeds `i64::MAX`. + /// issuer contract or the derivation path cannot be parsed, if either issuance amount + /// exceeds `i64::MAX`, or if both are zero. #[wasm_bindgen(js_name = addCovenantIssuanceInput)] #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] pub fn add_covenant_issuance_input( @@ -690,12 +701,7 @@ impl TransactionBuilder { include_debug_symbols: Option, derivation_path: Option, ) -> Result { - if i64::try_from(asset_amount_sats).is_err() { - return Err(JsError::new("asset issuance amount must not exceed i64::MAX")); - } - if i64::try_from(inflation_amount_sats).is_err() { - return Err(JsError::new("inflation amount must not exceed i64::MAX")); - } + Self::check_issuance_amounts(asset_amount_sats, inflation_amount_sats)?; let contract = Self::issuer_contract(issuer_contract_hex.as_deref()) .map_err(|e| JsError::new(&format!("Invalid issuer contract: {e}")))?; @@ -710,7 +716,7 @@ impl TransactionBuilder { include_debug_symbols, )?, IssuanceInput::new_issuance(asset_amount_sats, inflation_amount_sats, contract), - Self::required_signature(signature_witness.as_deref()), + Self::program_signature(signature_witness.as_deref()), ); Ok(IssuanceReport::from_details(&details)) @@ -774,7 +780,10 @@ impl TransactionBuilder { .as_ref() .ok_or_else(|| JsError::new(&format!("Input {input_index} is not a covenant input.")))?; - let (pst, _secrets) = self.transaction.extract_pst(); + let (pst, _secrets) = self + .transaction + .extract_pst() + .map_err(|e| JsError::new(&format!("Could not construct the transaction: {e}")))?; program_input .program @@ -813,24 +822,13 @@ impl TransactionBuilder { /// or a withess path if the signature is embedded like `SIGNATURE.Left.Right.1` /// /// `None` means no signature is required. - fn required_signature(signature_witness: Option<&str>) -> RequiredSignature { - let Some(raw) = signature_witness.map(str::trim).filter(|name| !name.is_empty()) else { - return RequiredSignature::None; - }; + fn program_signature(signature_witness: Option<&str>) -> Option { + let mut segments = signature_witness? + .split('.') + .map(str::trim) + .filter(|part| !part.is_empty()); - let mut segments = raw.split('.').map(str::trim).filter(|part| !part.is_empty()); - - let Some(name) = segments.next() else { - return RequiredSignature::None; - }; - - let path: Vec<&str> = segments.collect(); - - if path.is_empty() { - return RequiredSignature::Witness(name.to_string()); - } - - RequiredSignature::witness_with_path(name, path) + Some(ProgramSignature::new(segments.next()?).at_path(segments)) } fn input_at( @@ -927,9 +925,12 @@ impl TransactionBuilder { } } - /// Reads a derivation path relative to the account path, e.g. `0/7`. fn relative_path(path: &str) -> Result { - DerivationPath::from_str(path).map_err(|e| e.to_string()) + let path = DerivationPath::from_str(path).map_err(|e| e.to_string())?; + + Signer::check_derivation_path(&path).map_err(|e| e.to_string())?; + + Ok(path) } fn program_input( @@ -950,6 +951,22 @@ impl TransactionBuilder { witness: FixedWitness(witness).into(), }) } + + fn check_issuance_amounts(asset_amount_sats: u64, inflation_amount_sats: u64) -> Result<(), JsError> { + if i64::try_from(asset_amount_sats).is_err() { + return Err(JsError::new("asset issuance amount must not exceed i64::MAX")); + } + if i64::try_from(inflation_amount_sats).is_err() { + return Err(JsError::new("inflation amount must not exceed i64::MAX")); + } + if asset_amount_sats == 0 && inflation_amount_sats == 0 { + return Err(JsError::new( + "an issuance must create the asset, its reissuance token, or both", + )); + } + + Ok(()) + } } impl Default for TransactionBuilder { @@ -1317,7 +1334,7 @@ mod tests { // Read the issuance back the way Elements does. A null blinding nonce would make // this a new issuance of another asset. - let (pst, _) = builder.transaction.extract_pst(); + let (pst, _) = builder.transaction.extract_pst().unwrap(); let input = &pst.inputs()[0]; let (asset, minted_token) = input.issuance_ids(); @@ -1337,7 +1354,9 @@ mod tests { TransactionBuilder::relative_path("0/7").expect("a path").to_string(), "0/7" ); - assert!(TransactionBuilder::relative_path("not a path").is_err()); + for path in ["not a path", "", "m", "m/7", "7", "0/7h", "m/84h/1h/0h/0/7"] { + assert!(TransactionBuilder::relative_path(path).is_err(), "{path}"); + } } fn spent_output() -> String { diff --git a/examples/basic/Cargo.lock b/examples/basic/Cargo.lock index 0a05bcca..acb6677b 100644 --- a/examples/basic/Cargo.lock +++ b/examples/basic/Cargo.lock @@ -1516,7 +1516,7 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smplx-build" -version = "0.1.1" +version = "0.2.0" dependencies = [ "globwalk", "pathdiff", @@ -1534,7 +1534,7 @@ dependencies = [ [[package]] name = "smplx-macros" -version = "0.1.1" +version = "0.2.0" dependencies = [ "smplx-build", "smplx-test", @@ -1543,7 +1543,7 @@ dependencies = [ [[package]] name = "smplx-regtest" -version = "0.1.1" +version = "0.2.0" dependencies = [ "electrsd", "hex", @@ -1557,7 +1557,7 @@ dependencies = [ [[package]] name = "smplx-sdk" -version = "0.1.1" +version = "0.2.0" dependencies = [ "bip39", "bitcoincore-rpc", @@ -1567,14 +1567,13 @@ dependencies = [ "minreq 3.0.0", "serde", "serde_json", - "sha2", "simplicityhl", "thiserror", ] [[package]] name = "smplx-std" -version = "0.1.1" +version = "0.2.0" dependencies = [ "either", "rand 0.10.3", @@ -1588,7 +1587,7 @@ dependencies = [ [[package]] name = "smplx-test" -version = "0.1.1" +version = "0.2.0" dependencies = [ "electrsd", "proc-macro2", diff --git a/examples/basic/README.md b/examples/basic/README.md index e91def01..88401d47 100644 --- a/examples/basic/README.md +++ b/examples/basic/README.md @@ -44,7 +44,7 @@ Check out `confidential_test.rs` to learn how to issue and spend confidential UT ### Fuzzing -Fuzz tests are essential tools for asserting smart contract invariants and finding logical edge-cases via randomly generated arguments and witness. They are marked with `#[simplex::fuzz]` and run fully locally without interacting with the node. +Fuzz tests are essential tools for asserting smart contract invariants and finding logical edge-cases via randomly generated arguments, witness, storage, and transaction topology. They are marked with `#[simplex::fuzz]` and run fully locally without interacting with the node. Run the example fuzz test with the following command: diff --git a/examples/basic/tests/basic_fuzz.rs b/examples/basic/tests/basic_fuzz.rs index a89d2511..6c7594c3 100644 --- a/examples/basic/tests/basic_fuzz.rs +++ b/examples/basic/tests/basic_fuzz.rs @@ -1,16 +1,10 @@ -use simplex::simplicityhl::Arguments; use simplex::{FuzzMode, TestContext}; -use simplex::fuzz::args_strategy::ArgsStrategyBuilder; -use simplex::fuzz::fuzz_transaction::FuzzTransaction; -use simplex::fuzz::proptest::prelude::Strategy; +use simplex::fuzz::Scenario; use simplex::fuzz::proptest::test_runner::RngSeed; -use simplex::transaction::RequiredSignature; +use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput, ProgramSignature}; use simplex_example::artifacts::exceptional_contract::ExceptionalContractProgram; -use simplex_example::artifacts::exceptional_contract::derived_exceptional_contract::{ - ExceptionalContractArguments, ExceptionalContractWitness, -}; use simplex_example::artifacts::p2pk::P2pkProgram; use simplex_example::artifacts::p2pk::derived_p2pk::{P2pkArguments, P2pkWitness}; @@ -18,28 +12,25 @@ use simplex_example::artifacts::p2pk::derived_p2pk::{P2pkArguments, P2pkWitness} fn p2pk_fuzz_test(context: TestContext) -> anyhow::Result<()> { let signer = context.get_default_signer(); - let arguments = P2pkArguments { - public_key: signer.get_schnorr_public_key().serialize(), - }; - let fixed_arguments: Arguments = (&arguments).into(); + let public_key = signer.get_schnorr_public_key().serialize(); + let program = P2pkProgram::new(P2pkArguments { public_key }).as_ref().clone(); - // Keep the signer's public key fixed while fuzzing the initial witness. - let strategy = ArgsStrategyBuilder::::new() - .build() - .prop_map(move |(_, witness)| (fixed_arguments.clone(), witness)); + // Ask the signer to replace each fuzzed `SIGNATURE` with a valid signature. + let mut transaction = FinalTransaction::new(); + transaction.add_program_input( + PartialInput::default(), + ProgramInput::new(Box::new(program), P2pkWitness::default()), + Some(ProgramSignature::new("SIGNATURE")), + ); - let initial_transaction = FuzzTransaction::try_default()?.with_post_hook(|transaction, _, _, _| { - // Ask the signer to replace each fuzzed `SIGNATURE` with a valid signature. - transaction.inputs_mut()[0].required_sig = RequiredSignature::Witness("SIGNATURE".to_string()); + let mut scenario = Scenario::new(transaction); + // Keep the signer's public key fixed while fuzzing the witness. + scenario + .add::("p2pk") + .spends(0) + .bind_arguments(move |arguments, _| arguments.public_key = public_key); - Ok(()) - }); - - context - .engine::() - .with_custom_strategy(strategy) - .with_custom_transaction(initial_transaction) - .run(); + context.engine(scenario).run(); Ok(()) } @@ -51,8 +42,8 @@ fn test_panic(test_context: TestContext) { config.cases = 66_000; config.rng_seed = RngSeed::Fixed(0x0000_0034); - test_context - .engine::() - .with_custom_config(config) - .run(); + let mut scenario = Scenario::default(); + scenario.add::("exceptional").spends(0); + + test_context.engine(scenario).with_custom_config(config).run(); } diff --git a/examples/basic/tests/basic_test.rs b/examples/basic/tests/basic_test.rs index ed219376..23bf5617 100644 --- a/examples/basic/tests/basic_test.rs +++ b/examples/basic/tests/basic_test.rs @@ -1,6 +1,6 @@ use simplex::simplicityhl::elements::Script; -use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput, RequiredSignature, TxReceipt}; +use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput, ProgramSignature, TxReceipt}; use simplex_example::artifacts::p2pk::P2pkProgram; use simplex_example::artifacts::p2pk::derived_p2pk::{P2pkArguments, P2pkWitness}; @@ -44,7 +44,7 @@ fn spend_p2pk(context: &simplex::TestContext) -> anyhow::Result> { ft.add_program_input( PartialInput::new(p2pk_utxos[0].clone()), ProgramInput::new(Box::new(p2pk.as_ref().clone()), witness.clone()), - RequiredSignature::Witness("SIGNATURE".to_string()), + Some(ProgramSignature::new("SIGNATURE")), ); let tx_receipt = signer.broadcast(&ft)?; diff --git a/examples/basic/tests/confidential_test.rs b/examples/basic/tests/confidential_test.rs index f5bbab22..5c54e819 100644 --- a/examples/basic/tests/confidential_test.rs +++ b/examples/basic/tests/confidential_test.rs @@ -2,7 +2,7 @@ use simplex::simplicityhl::elements::AssetId; use simplex::signer::Signer; use simplex::transaction::partial_input::IssuanceInput; -use simplex::transaction::{FinalTransaction, PartialInput, PartialOutput, RequiredSignature, TxReceipt}; +use simplex::transaction::{FinalTransaction, NativeSignature, PartialInput, PartialOutput, TxReceipt}; fn make_confidential_to_bob<'a>(alice: &'a Signer, bob: &Signer, asset: AssetId) -> anyhow::Result> { let mut ft = FinalTransaction::new(); @@ -26,7 +26,7 @@ fn issue_confidential_to_alice<'a>(alice: &Signer, bob: &'a Signer) -> anyhow::R let issuance_details = ft.add_issuance_input( PartialInput::new(utxos[0].clone()), IssuanceInput::new_issuance(1000, 100, [1u8; 32]), - RequiredSignature::NativeEcdsa, + Some(NativeSignature::Ecdsa), ); ft.add_output( diff --git a/fixtures/Cargo.lock b/fixtures/Cargo.lock index bfd12aa5..a97a8ca2 100644 --- a/fixtures/Cargo.lock +++ b/fixtures/Cargo.lock @@ -1517,7 +1517,7 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smplx-build" -version = "0.1.1" +version = "0.2.0" dependencies = [ "globwalk", "pathdiff", @@ -1535,7 +1535,7 @@ dependencies = [ [[package]] name = "smplx-macros" -version = "0.1.1" +version = "0.2.0" dependencies = [ "smplx-build", "smplx-test", @@ -1544,7 +1544,7 @@ dependencies = [ [[package]] name = "smplx-regtest" -version = "0.1.1" +version = "0.2.0" dependencies = [ "electrsd", "hex", @@ -1558,7 +1558,7 @@ dependencies = [ [[package]] name = "smplx-sdk" -version = "0.1.1" +version = "0.2.0" dependencies = [ "bip39", "bitcoincore-rpc", @@ -1568,14 +1568,13 @@ dependencies = [ "minreq 3.0.0", "serde", "serde_json", - "sha2", "simplicityhl", "thiserror", ] [[package]] name = "smplx-std" -version = "0.1.1" +version = "0.2.0" dependencies = [ "either", "rand 0.10.3", @@ -1589,7 +1588,7 @@ dependencies = [ [[package]] name = "smplx-test" -version = "0.1.1" +version = "0.2.0" dependencies = [ "electrsd", "proc-macro2", diff --git a/fixtures/Cargo.toml b/fixtures/Cargo.toml index 1914ce8a..f1621141 100644 --- a/fixtures/Cargo.toml +++ b/fixtures/Cargo.toml @@ -4,6 +4,9 @@ edition = "2024" rust-version = "1.91.0" version = "0.1.0" +[profile.dev.package."*"] +opt-level = 3 + [dependencies] smplx-std = { path = "../crates/simplex" } diff --git a/fixtures/simf/conserving_vault.simf b/fixtures/simf/conserving_vault.simf new file mode 100644 index 00000000..5f397b15 --- /dev/null +++ b/fixtures/simf/conserving_vault.simf @@ -0,0 +1,17 @@ +/* + * Pays the whole input, in its asset, to the unvault at output 0. + */ +fn main() { + assert!(jet::eq_256(unwrap(jet::output_script_hash(0)), param::UNVAULT_SCRIPT_HASH)); + + let (input_asset, input_amount): (Asset1, Amount1) = jet::current_amount(); + let (output_asset, output_amount): (Asset1, Amount1) = unwrap(jet::output_amount(0)); + + let input_asset: u256 = unwrap_right::<(u1, u256)>(input_asset); + let output_asset: u256 = unwrap_right::<(u1, u256)>(output_asset); + assert!(jet::eq_256(input_asset, output_asset)); + + let input_amount: u64 = unwrap_right::<(u1, u256)>(input_amount); + let output_amount: u64 = unwrap_right::<(u1, u256)>(output_amount); + assert!(jet::eq_64(input_amount, output_amount)); +} diff --git a/fixtures/simf/counter.simf b/fixtures/simf/counter.simf new file mode 100644 index 00000000..9eb9ff9b --- /dev/null +++ b/fixtures/simf/counter.simf @@ -0,0 +1,40 @@ +/* + * Adds a witness step, at most MAX_STEP, to the lowest 64 bits of the state stored in its taproot tree. + */ + +fn script_hash_for_state(state: u256) -> u256 { + let tap_leaf: u256 = jet::tapleaf_hash(); + let state_ctx1: Ctx8 = jet::tapdata_init(); + let state_ctx2: Ctx8 = jet::sha_256_ctx_8_add_32(state_ctx1, state); + let state_leaf: u256 = jet::sha_256_ctx_8_finalize(state_ctx2); + let tap_node: u256 = jet::build_tapbranch(tap_leaf, state_leaf); + + let bip0341_key: u256 = 0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0; + let tweaked_key: u256 = jet::build_taptweak(bip0341_key, tap_node); + + let hash_ctx1: Ctx8 = jet::sha_256_ctx_8_init(); + let hash_ctx2: Ctx8 = jet::sha_256_ctx_8_add_2(hash_ctx1, 0x5120); + let hash_ctx3: Ctx8 = jet::sha_256_ctx_8_add_32(hash_ctx2, tweaked_key); + jet::sha_256_ctx_8_finalize(hash_ctx3) +} + +fn main() { + let state: u256 = witness::STATE; + assert!(jet::eq_256( + script_hash_for_state(state), + unwrap(jet::input_script_hash(jet::current_index())) + )); + + let step: u64 = witness::STEP; + assert!(jet::le_64(step, param::MAX_STEP)); + + let (state1, state2, state3, state4): (u64, u64, u64, u64) = ::into(state); + let (carry, next_state4): (bool, u64) = jet::add_64(state4, step); + assert!(jet::eq_1(::into(carry), 0)); + + let next_state: u256 = <(u64, u64, u64, u64)>::into((state1, state2, state3, next_state4)); + assert!(jet::eq_256( + script_hash_for_state(next_state), + unwrap(jet::output_script_hash(jet::current_index())) + )); +} diff --git a/fixtures/simf/dummy_accept.simf b/fixtures/simf/dummy_accept.simf new file mode 100644 index 00000000..432fe8cc --- /dev/null +++ b/fixtures/simf/dummy_accept.simf @@ -0,0 +1,3 @@ +fn main() { + assert!(true); +} diff --git a/fixtures/simf/imports/multidep.simf b/fixtures/simf/imports/multidep.simf index 257d8d72..a58c7b31 100644 --- a/fixtures/simf/imports/multidep.simf +++ b/fixtures/simf/imports/multidep.simf @@ -9,7 +9,7 @@ fn get_block_value_hash(prev_hash: u32, tx1: u32, tx2: u32) -> u32 { fn main() { let block_val_hash: u32 = get_block_value_hash(param::PREV_HASH, 10, 20); assert_eq_32(block_val_hash, 27); - + let first_value: u32 = witness::TX1; let second_value: u32 = 22; assert_eq_32(and_hash(first_value, second_value), 6); diff --git a/fixtures/simf/paired_output.simf b/fixtures/simf/paired_output.simf new file mode 100644 index 00000000..7e867f0a --- /dev/null +++ b/fixtures/simf/paired_output.simf @@ -0,0 +1,10 @@ +/* + * Spends when the output at its own input index pays NEXT_SCRIPT_HASH. + */ + +fn main() { + let next_script_hash: u256 = param::NEXT_SCRIPT_HASH; + let output_script_hash: u256 = unwrap(jet::output_script_hash(jet::current_index())); + + assert!(jet::eq_256(output_script_hash, next_script_hash)); +} diff --git a/fixtures/simf/requires_input.simf b/fixtures/simf/requires_input.simf new file mode 100644 index 00000000..11cd38f0 --- /dev/null +++ b/fixtures/simf/requires_input.simf @@ -0,0 +1,6 @@ +fn main() { + let required_script_hash: u256 = param::REQUIRED_SCRIPT_HASH; + let input_script_hash: u256 = unwrap(jet::input_script_hash(1)); + + assert!(jet::eq_256(input_script_hash, required_script_hash)); +} diff --git a/fixtures/simf/script_auth.simf b/fixtures/simf/script_auth.simf new file mode 100644 index 00000000..a51ff620 --- /dev/null +++ b/fixtures/simf/script_auth.simf @@ -0,0 +1,10 @@ +/* + * Spends when the input the witness points at has AUTH_SCRIPT_HASH. + */ + +fn main() { + let auth_script_hash: u256 = param::AUTH_SCRIPT_HASH; + let auth_input: u32 = witness::AUTH_INPUT; + + assert!(jet::eq_256(unwrap(jet::input_script_hash(auth_input)), auth_script_hash)); +} diff --git a/fixtures/simf/timelock.simf b/fixtures/simf/timelock.simf new file mode 100644 index 00000000..a7f1f7c0 --- /dev/null +++ b/fixtures/simf/timelock.simf @@ -0,0 +1,8 @@ +/* + * Spends once the transaction is locked to at least LOCK_HEIGHT. + */ +fn main() { + let lock_height: u32 = param::LOCK_HEIGHT; + + jet::check_lock_height(lock_height); +} diff --git a/fixtures/simf/unvault.simf b/fixtures/simf/unvault.simf new file mode 100644 index 00000000..e52a67aa --- /dev/null +++ b/fixtures/simf/unvault.simf @@ -0,0 +1,5 @@ +fn main() { + let delay: u16 = param::DELAY; + + assert!(jet::le_16(delay, 1000)); +} diff --git a/fixtures/simf/vault.simf b/fixtures/simf/vault.simf new file mode 100644 index 00000000..df1e86fe --- /dev/null +++ b/fixtures/simf/vault.simf @@ -0,0 +1,6 @@ +fn main() { + let unvault_script_hash: u256 = param::UNVAULT_SCRIPT_HASH; + let output_script_hash: u256 = unwrap(jet::output_script_hash(0)); + + assert!(jet::eq_256(output_script_hash, unvault_script_hash)); +} diff --git a/fixtures/tests/basic_test.rs b/fixtures/tests/basic_test.rs index c5b0b521..4becb896 100644 --- a/fixtures/tests/basic_test.rs +++ b/fixtures/tests/basic_test.rs @@ -1,6 +1,6 @@ use simplex::simplicityhl::elements::Script; -use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput, RequiredSignature}; +use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput, ProgramSignature}; use simplex_fixtures::artifacts::p2pk::P2pkProgram; use simplex_fixtures::artifacts::p2pk::derived_p2pk::{P2pkArguments, P2pkWitness}; @@ -44,7 +44,7 @@ fn spend_p2pk(context: &simplex::TestContext) -> anyhow::Result<()> { ft.add_program_input( PartialInput::new(p2pk_utxos[0].clone()), ProgramInput::new(Box::new(p2pk.as_ref().clone()), witness), - RequiredSignature::Witness("SIGNATURE".to_string()), + Some(ProgramSignature::new("SIGNATURE")), ); let tx_receipt = signer.broadcast(&ft)?; diff --git a/fixtures/tests/budget_test.rs b/fixtures/tests/budget_test.rs index ceed3363..b9a8146d 100644 --- a/fixtures/tests/budget_test.rs +++ b/fixtures/tests/budget_test.rs @@ -3,7 +3,7 @@ use simplex::either::Either; use simplex::program::ProgramError; use simplex::signer::SignerError; use simplex::simplicityhl::elements::Script; -use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput, RequiredSignature}; +use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput, ProgramSignature}; use simplex_fixtures::artifacts::budget::BudgetProgram; use simplex_fixtures::artifacts::budget::derived_budget::{BudgetArguments, BudgetWitness}; @@ -42,7 +42,7 @@ fn spend_budget( ft.add_program_input( PartialInput::new(utxos[0].clone()), ProgramInput::new(Box::new(program.as_ref().clone()), witness), - RequiredSignature::witness_with_path("SIGNATURE", [sig_path]), + Some(ProgramSignature::new("SIGNATURE").at_path([sig_path])), ); Ok(ft) diff --git a/fixtures/tests/confidential_test.rs b/fixtures/tests/confidential_test.rs index 5e1784d8..c8b1b140 100644 --- a/fixtures/tests/confidential_test.rs +++ b/fixtures/tests/confidential_test.rs @@ -2,7 +2,7 @@ use simplex::simplicityhl::elements::AssetId; use simplex::signer::Signer; use simplex::transaction::partial_input::IssuanceInput; -use simplex::transaction::{FinalTransaction, PartialInput, PartialOutput, RequiredSignature}; +use simplex::transaction::{FinalTransaction, NativeSignature, PartialInput, PartialOutput}; fn make_confidential_to_bob(alice: &Signer, bob: &Signer, asset: AssetId) -> anyhow::Result<()> { let mut ft = FinalTransaction::new(); @@ -26,7 +26,7 @@ fn issue_confidential_to_alice(alice: &Signer, bob: &Signer) -> anyhow::Result<( let issuance_details = ft.add_issuance_input( PartialInput::new(utxos[0].clone()), IssuanceInput::new_issuance(1000, 100, [1u8; 32]), - RequiredSignature::NativeEcdsa, + Some(NativeSignature::Ecdsa), ); ft.add_output( diff --git a/fixtures/tests/fuzz_mutation.rs b/fixtures/tests/fuzz_mutation.rs new file mode 100644 index 00000000..715003cb --- /dev/null +++ b/fixtures/tests/fuzz_mutation.rs @@ -0,0 +1,217 @@ +use simplex::{FuzzMode, TestContext}; + +use simplex::fuzz::{Expect, FuzzCase, Mutation, Mutations, Scenario}; +use simplex::simplicityhl::elements::{AssetId, LockTime, OutPoint, Script, Sequence}; +use simplex::transaction::{FinalTransaction, PartialInput, PartialOutput, UTXO}; + +use simplex_fixtures::artifacts::conserving_vault::ConservingVaultProgram; +use simplex_fixtures::artifacts::dummy_accept::DummyAcceptProgram; +use simplex_fixtures::artifacts::paired_output::PairedOutputProgram; +use simplex_fixtures::artifacts::script_auth::ScriptAuthProgram; +use simplex_fixtures::artifacts::timelock::TimelockProgram; +use simplex_fixtures::artifacts::unvault::UnvaultProgram; +use simplex_fixtures::artifacts::vault::VaultProgram; + +/// Input 0 holds 1000 of the vault's coins, output 0 pays them to the unvault and output 1 is the fee. +fn vault_scenario(fee: u64) -> Scenario { + let asset = AssetId::from_byte_array([1; 32]); + + let mut transaction = FinalTransaction::new(); + transaction.add_input( + PartialInput::new(UTXO::explicit(OutPoint::default(), Script::new(), 1000, asset)), + None, + ); + transaction.add_output(PartialOutput::new(Script::new(), 1000 - fee, asset)); + transaction.add_output(PartialOutput::new(Script::new(), 0, asset)); + + let mut scenario = Scenario::new(transaction); + scenario.add::("unvault").locks(0).random_strategy(); + + scenario +} + +/// Output 0 pays the vault's whole input, in its asset, to the unvault. +fn pays_unvault(case: &FuzzCase<'_>) -> Expect { + let unvault = case.program::("unvault"); + let vault = case.input_index(0); + + if case.output_script_hash(0) == unvault.script_hash() + && case.output_amount(0) == case.input_amount(vault) + && case.output_asset(0) == case.input_asset(vault) + { + Expect::Accept + } else { + Expect::Reject + } +} + +#[should_panic(expected = "unsound: the contract accepted a spend the spec rejects")] +#[simplex::fuzz] +fn vault_that_ignores_the_amount_is_unsound(test_context: TestContext) { + script_only_vault(&test_context, Mutations::none().amounts()); +} + +#[should_panic(expected = "unsound: the contract accepted a spend the spec rejects")] +#[simplex::fuzz] +fn vault_that_ignores_the_asset_is_unsound(test_context: TestContext) { + script_only_vault(&test_context, Mutations::none().assets()); +} + +/// The fixture vault checks only the script of output 0. +fn script_only_vault(test_context: &TestContext, mutations: Mutations) { + let mut scenario = vault_scenario(0); + + scenario + .add::("vault") + .spends(0) + .bind_arguments(|arguments, case| { + arguments.unvault_script_hash = case.program::("unvault").script_hash(); + }); + + test_context + .engine(scenario) + .mutations(mutations) + .spec(pays_unvault) + .run(); +} + +#[simplex::fuzz] +fn conserving_vault_passes(test_context: TestContext) { + let mut scenario = vault_scenario(0); + + scenario + .add::("vault") + .spends(0) + .bind_arguments(|arguments, case| { + arguments.unvault_script_hash = case.program::("unvault").script_hash(); + }); + + test_context + .engine(scenario) + .mutations(Mutations::default().layout()) + .spec(pays_unvault) + .run(); +} + +#[should_panic(expected = "mutations need a balanced transaction")] +#[simplex::fuzz] +fn unbalanced_transaction_is_a_setup_error(test_context: TestContext) { + let mut scenario = vault_scenario(1); + scenario.add::("vault").spends(0); + + test_context.engine(scenario).mutations(Mutations::default()).run(); +} + +/// The template sets the locktime to 100, which makes input 0 enforce it. +#[simplex::fuzz] +fn timelock_passes_with_a_spec_on_the_lock_height(test_context: TestContext) { + let mut transaction = FinalTransaction::new(); + transaction.add_input(PartialInput::default(), None); + transaction.set_locktime(LockTime::from_consensus(100)); + + let mut scenario = Scenario::new(transaction); + scenario + .add::("timelock") + .spends(0) + .bind_arguments(|arguments, _| arguments.lock_height = 100); + + test_context + .engine(scenario) + .mutations(Mutations::none().timelocks()) + .spec(|case| { + if case.lock_height() >= 100 { + Expect::Accept + } else { + Expect::Reject + } + }) + .run(); +} + +/// Input 0 is the program's and output 0 pays the script it expects. +#[simplex::fuzz] +fn paired_output_is_judged_under_layout(test_context: TestContext) { + let mut transaction = FinalTransaction::new(); + transaction.add_input(PartialInput::default(), None); + transaction.add_output(PartialOutput::new( + Script::new_op_return(b"next"), + 0, + AssetId::default(), + )); + + let mut scenario = Scenario::new(transaction); + scenario + .add::("paired_output") + .spends(0) + .bind_arguments(|arguments, case| arguments.next_script_hash = case.output_script_hash(case.output_index(0))); + + test_context + .engine(scenario) + .mutations(Mutations::none().layout().everywhere()) + .spec(|case| { + if case.input_index(0) == case.output_index(0) { + Expect::Accept + } else { + Expect::Reject + } + }) + .run(); +} + +/// Input 1 holds the auth UTXO the witness points at. +#[simplex::fuzz] +fn auth_input_requirement_follows_its_utxo(test_context: TestContext) { + let auth = UTXO::explicit( + OutPoint::default(), + Script::new_op_return(b"auth"), + 0, + AssetId::default(), + ); + + let mut transaction = FinalTransaction::new(); + transaction.add_input(PartialInput::default(), None); + transaction.add_input(PartialInput::new(auth), None); + + let mut scenario = Scenario::new(transaction); + scenario + .add::("script_auth") + .spends(0) + .bind_arguments(|arguments, case| arguments.auth_script_hash = case.input_script_hash(case.input_index(1))) + .requires_witness(|witness, case| witness.auth_input = case.input_index(1) as u32); + + test_context + .engine(scenario) + .mutations(Mutations::none().layout()) + .run(); +} + +/// The transaction enables the locktime, so a final input has to override it. +#[simplex::fuzz] +fn sequence_mutation_reaches_the_signed_transaction(test_context: TestContext) { + let mut transaction = FinalTransaction::new(); + transaction.set_sequence(Sequence::ENABLE_LOCKTIME_NO_RBF); + transaction.add_input(PartialInput::default(), None); + + let mut scenario = Scenario::new(transaction); + scenario.add::("accepting").spends(0); + + test_context + .engine(scenario) + .mutations(Mutations::none().timelocks()) + .spec(|case| { + let mutated = case.mutations().iter().rev().find_map(|mutation| match mutation { + Mutation::SetSequence { input: 0, sequence } => Some(*sequence), + _ => None, + }); + + if let Some(sequence) = mutated { + let signed = case.transaction().extract_pst().unwrap().0.extract_tx().unwrap(); + + assert_eq!(signed.input[0].sequence, sequence); + assert_eq!(case.input_sequence(0), sequence); + } + + Expect::Accept + }) + .run(); +} diff --git a/fixtures/tests/fuzz_scenario.rs b/fixtures/tests/fuzz_scenario.rs new file mode 100644 index 00000000..0c50dab6 --- /dev/null +++ b/fixtures/tests/fuzz_scenario.rs @@ -0,0 +1,244 @@ +use simplex::{FuzzMode, TestContext}; + +use simplex::fuzz::{Expect, Scenario}; +use simplex::simplicityhl::WitnessValues; +use simplex::simplicityhl::elements::{AssetId, OutPoint, Txid}; +use simplex::transaction::{FinalTransaction, PartialInput, PartialOutput, ProgramInput, UTXO}; + +use simplex_fixtures::artifacts::counter::CounterProgram; +use simplex_fixtures::artifacts::dummy_accept::DummyAcceptProgram; +use simplex_fixtures::artifacts::dummy_accept::derived_dummy_accept::DummyAcceptArguments; +use simplex_fixtures::artifacts::dummy_panic::DummyPanicProgram; +use simplex_fixtures::artifacts::requires_input::RequiresInputProgram; +use simplex_fixtures::artifacts::tapleaf_check::TapleafCheckProgram; +use simplex_fixtures::artifacts::unvault::UnvaultProgram; +use simplex_fixtures::artifacts::vault::VaultProgram; + +fn two_inputs() -> FinalTransaction { + let mut transaction = FinalTransaction::new(); + + for vout in 0..2 { + let utxo = UTXO { + outpoint: OutPoint::new(Txid::from_byte_array(Default::default()), vout), + ..UTXO::default() + }; + + transaction.add_input(PartialInput::new(utxo), None); + } + + transaction +} + +fn one_input_one_output() -> FinalTransaction { + let mut transaction = FinalTransaction::new(); + transaction.add_input(PartialInput::default(), None); + transaction.add_output(PartialOutput::default()); + + transaction +} + +fn accepting() -> Scenario { + let mut scenario = Scenario::default(); + scenario.add::("accepting").spends(0); + + scenario +} + +fn rejecting() -> Scenario { + let mut scenario = Scenario::default(); + scenario.add::("rejecting").spends(0); + + scenario +} + +#[should_panic(expected = "liveness: the contract rejected a spend the spec accepts \ + (input 0 `accepting` accepted, input 1 `rejecting` rejected (assertion))")] +#[simplex::fuzz] +fn co_spent_program_that_rejects_is_named(test_context: TestContext) { + let mut scenario = Scenario::new(two_inputs()); + + scenario.add::("accepting").spends(0); + scenario.add::("rejecting").spends(1); + + test_context.engine(scenario).run(); +} + +#[should_panic(expected = "unsound: the contract accepted a spend the spec rejects")] +#[simplex::fuzz] +fn spec_rejecting_an_accepted_spend_is_unsound(test_context: TestContext) { + test_context.engine(accepting()).spec(|_| Expect::Reject).run(); +} + +#[should_panic(expected = "the spec panicked")] +#[simplex::fuzz] +fn panicking_spec_is_a_setup_error(test_context: TestContext) { + test_context.engine(accepting()).spec(|_| panic!("spec bug")).run(); +} + +#[should_panic(expected = "The spec made no prediction")] +#[simplex::fuzz] +fn spec_without_predictions_fails(test_context: TestContext) { + test_context.engine(accepting()).spec(|_| Expect::Any).run(); +} + +#[should_panic(expected = "No case was accepted")] +#[simplex::fuzz] +fn run_without_acceptance_fails(test_context: TestContext) { + test_context.engine(rejecting()).spec(|_| Expect::Reject).run(); +} + +#[simplex::fuzz] +fn allow_unspendable_lets_a_rejecting_run_pass(test_context: TestContext) { + test_context + .engine(rejecting()) + .spec(|_| Expect::Reject) + .allow_unspendable() + .run(); +} + +#[simplex::fuzz] +fn vault_bound_to_its_unvault_passes(test_context: TestContext) { + let mut scenario = Scenario::new(one_input_one_output()); + + scenario.add::("unvault").locks(0).random_strategy(); + scenario + .add::("vault") + .spends(0) + .bind_arguments(|arguments, case| { + arguments.unvault_script_hash = case.program::("unvault").script_hash(); + }); + + test_context.engine(scenario).run(); +} + +#[simplex::fuzz] +fn program_bound_to_a_fixed_input_passes(test_context: TestContext) { + let fixed = DummyAcceptProgram::new(DummyAcceptArguments::default()); + + let fixed_utxo = UTXO::explicit( + OutPoint::new(Txid::from_byte_array(Default::default()), 1), + fixed.get_script_pubkey(test_context.get_network()), + 0, + AssetId::default(), + ); + + let mut transaction = FinalTransaction::new(); + transaction.add_input(PartialInput::default(), None); + transaction.add_program_input( + PartialInput::new(fixed_utxo), + ProgramInput::new(Box::new(fixed.as_ref().clone()), WitnessValues::default()), + None, + ); + + let mut scenario = Scenario::new(transaction); + + scenario + .add::("requires_input") + .spends(0) + .bind_arguments(|arguments, case| { + arguments.required_script_hash = case.input_script_hash(1); + }); + + test_context.engine(scenario).run(); +} + +#[simplex::fuzz] +fn tapleaf_check_requiring_its_own_tapleaf_passes(test_context: TestContext) { + let mut scenario = Scenario::default(); + + scenario + .add::("tapleaf_check") + .spends(0) + .requires_witness(|witness, case| { + witness.program_tapleaf_hash = case + .program::("tapleaf_check") + .program() + .get_tapleaf_hash(); + }); + + test_context.engine(scenario).run(); +} + +/// The counter adds `STEP` to the lowest 64 bits of its state. `extra` makes the modelled transition wrong. +/// `step` requires a step the counter doesn't enforce. +fn counter(test_context: &TestContext, extra: u64, step: Option) { + let mut scenario = Scenario::new(one_input_one_output()); + + let mut counter = scenario + .add::("counter") + .storage_capacity(1) + .spends(0) + .requires_witness(|witness, case| { + witness.state = case.program::("counter").storage()[0]; + }); + + if let Some(step) = step { + counter = counter.requires_witness(move |witness, _| witness.step = step); + } + + counter.recursive(0, move |counter| { + let mut state = counter.storage()[0]; + let low = u64::from_be_bytes(state[24..].try_into().unwrap()) + .wrapping_add(counter.witness().step) + .wrapping_add(extra); + + state[24..].copy_from_slice(&low.to_be_bytes()); + + vec![state] + }); + + test_context + .engine(scenario) + .spec(|case| { + let counter = case.program::("counter"); + let low = u64::from_be_bytes(counter.storage()[0][24..].try_into().unwrap()); + let step = counter.witness().step; + + if step <= counter.arguments().max_step && low.checked_add(step).is_some() { + Expect::Accept + } else { + Expect::Reject + } + }) + .run(); +} + +#[simplex::fuzz] +fn counter_with_its_transition_passes(test_context: TestContext) { + counter(&test_context, 0, None); +} + +#[should_panic(expected = "liveness: the contract rejected a spend the spec accepts")] +#[simplex::fuzz] +fn counter_with_an_off_by_one_transition_is_live(test_context: TestContext) { + counter(&test_context, 1, None); +} + +#[should_panic( + expected = "unsound: the contract accepted a spend that violates a requirement, `counter` witness `STEP`" +)] +#[simplex::fuzz] +fn counter_requiring_a_step_it_does_not_enforce_is_unsound(test_context: TestContext) { + counter(&test_context, 0, Some(1)); +} + +#[should_panic(expected = "lock bypass: output 0 (locked to `unvault`) can be redirected")] +#[simplex::fuzz] +fn vault_that_ignores_its_output_is_redirected(test_context: TestContext) { + let mut scenario = Scenario::new(one_input_one_output()); + + scenario.add::("unvault").locks(0); + scenario.add::("leaky_vault").spends(0); + + test_context.engine(scenario).run(); +} + +#[simplex::fuzz] +fn unchecked_lock_may_be_redirected(test_context: TestContext) { + let mut scenario = Scenario::new(one_input_one_output()); + + scenario.add::("unvault").locks_unchecked(0); + scenario.add::("key_path").spends(0); + + test_context.engine(scenario).run(); +} diff --git a/fixtures/tests/fuzz_testing.rs b/fixtures/tests/fuzz_testing.rs index f679e10a..39aa404d 100644 --- a/fixtures/tests/fuzz_testing.rs +++ b/fixtures/tests/fuzz_testing.rs @@ -1,130 +1,98 @@ -use simplex::simplicityhl::elements::pset::PartiallySignedTransaction; -use simplex::simplicityhl::{Arguments, WitnessValues}; use simplex::{FuzzMode, TestContext}; -use simplex::fuzz::fuzz_transaction::{FuzzTransaction, ProgramTarget}; -use simplex::fuzz::proptest::strategy::Just; -use simplex::fuzz::{FuzzError, ProgramCheck, ProgramExecResult}; -use simplex::signer::Signer; -use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput, RequiredSignature, UTXO}; +use simplex::fuzz::{Expect, Scenario}; +use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput, ProgramSignature}; use simplex_fixtures::artifacts::failure_test::FailureTestProgram; -use simplex_fixtures::artifacts::failure_test::derived_failure_test::{FailureTestArguments, FailureTestWitness}; +use simplex_fixtures::artifacts::nested_sig::NestedSigProgram; +use simplex_fixtures::artifacts::nested_sig::derived_nested_sig::{NestedSigArguments, NestedSigWitness}; use simplex_fixtures::artifacts::p2pk::P2pkProgram; use simplex_fixtures::artifacts::p2pk::derived_p2pk::{P2pkArguments, P2pkWitness}; -#[derive(Clone, Copy, Eq, PartialEq)] -pub enum Expect { - Ok, - Failure, -} +#[simplex::fuzz] +fn failure_test_matches_its_spec(test_context: TestContext) -> anyhow::Result<()> { + let mut scenario = Scenario::default(); + scenario.add::("failure").spends(0); -struct FailureProgramCheck { - expect: Expect, -} + test_context + .engine(scenario) + .spec(|case| { + let failure = case.program::("failure"); + + if failure.arguments().failure_value == failure.witness().cmp_value { + Expect::Reject + } else { + Expect::Accept + } + }) + .run(); -struct SuccessfulProgramCheck; - -impl ProgramCheck for FailureProgramCheck { - fn call( - &self, - _signer: &Signer, - _tx: &PartiallySignedTransaction, - _arguments: &Arguments, - _witness: &WitnessValues, - _input_index: usize, - program_exec_result: ProgramExecResult, - ) -> Result<(), String> { - let args = FailureTestArguments::from_arguments(_arguments)?; - let witness = FailureTestWitness::from_witness(_witness)?; - - if (args.failure_value == witness.cmp_value || program_exec_result.is_err()) && self.expect == Expect::Ok { - return Err(format!( - "Failed contract, failure_value == cmp_value , {program_exec_result:?}" - )); - } - - Ok(()) - } + Ok(()) } -impl ProgramCheck for SuccessfulProgramCheck { - fn call( - &self, - _signer: &Signer, - _tx: &PartiallySignedTransaction, - _arguments: &Arguments, - witness: &WitnessValues, - _input_index: usize, - program_exec_result: ProgramExecResult, - ) -> Result<(), String> { - let signed_witness = P2pkWitness::from_witness(witness)?; - - if signed_witness == P2pkWitness::default() { - return Err("fuzz check received the original unsigned witness".to_string()); - } - - program_exec_result - .map(|_| ()) - .map_err(|error| format!("signed program failed: {error}")) - } +#[should_panic(expected = "liveness: the contract rejected a spend the spec accepts")] +#[simplex::fuzz] +fn default_spec_reports_liveness(test_context: TestContext) { + let mut scenario = Scenario::default(); + scenario.add::("failure").spends(0); + + test_context.engine(scenario).run(); } -fn initial_fuzz_transaction(arguments: &P2pkArguments, witness: &P2pkWitness) -> Result { - const FUZZ_PROGRAM_TARGET: ProgramTarget = ProgramTarget::Input(0); +#[simplex::fuzz] +fn signed_witness_is_accepted(test_context: TestContext) -> anyhow::Result<()> { + let signer = test_context.get_default_signer(); - let program = P2pkProgram::new(arguments.clone()); - let mut transaction = FinalTransaction::new(); + let public_key = signer.get_schnorr_public_key().serialize(); + let mut transaction = FinalTransaction::new(); transaction.add_program_input( - PartialInput::new(UTXO::default()), - ProgramInput::new(Box::new(program.as_ref().clone()), witness), - RequiredSignature::Witness("SIGNATURE".to_string()), + PartialInput::default(), + ProgramInput::new( + Box::new(P2pkProgram::new(P2pkArguments { public_key }).as_ref().clone()), + P2pkWitness::default(), + ), + Some(ProgramSignature::new("SIGNATURE")), ); - FuzzTransaction::new(transaction, [FUZZ_PROGRAM_TARGET]) -} + let mut scenario = Scenario::new(transaction); + scenario + .add::("p2pk") + .spends(0) + .bind_arguments(move |arguments, _| arguments.public_key = public_key); -#[simplex::fuzz] -fn test_failure_ignoring_in_fuzzing(test_context: TestContext) -> anyhow::Result<()> { - test_context - .engine::() - .with_custom_check(FailureProgramCheck { - expect: Expect::Failure, - }) - .run(); + test_context.engine(scenario).run(); Ok(()) } -#[should_panic(expected = "Program failed with these arguments")] +/// The hot path signs with the first of its two signatures, the second is never checked. #[simplex::fuzz] -fn test_panic_after_fuzzing(test_context: TestContext) { - test_context - .engine::() - .with_custom_check(FailureProgramCheck { expect: Expect::Ok }) - .run(); -} +fn nested_sig_spends_through_its_hot_path(test_context: TestContext) -> anyhow::Result<()> { + let public_key = test_context.get_default_signer().get_schnorr_public_key().serialize(); -#[simplex::fuzz] -fn test_signed_witness_with_program_checks(test_context: TestContext) -> anyhow::Result<()> { - let signer = test_context.get_default_signer(); - - let arguments = P2pkArguments { - public_key: signer.get_schnorr_public_key().serialize(), - }; - - let witness = P2pkWitness::default(); - let strategy = Just(((&arguments).into(), (&witness).into())); + let mut transaction = FinalTransaction::new(); + transaction.add_program_input( + PartialInput::default(), + ProgramInput::new( + Box::new( + NestedSigProgram::new(NestedSigArguments { public_key }) + .as_ref() + .clone(), + ), + NestedSigWitness::default(), + ), + Some(ProgramSignature::new("INHERIT_OR_NOT").at_path(["Right", "Right", "0"])), + ); - let transaction = initial_fuzz_transaction(&arguments, &witness)?; + let mut scenario = Scenario::new(transaction); + scenario + .add::("nested_sig") + .spends(0) + .spending_path("INHERIT_OR_NOT", ["Right", "Right"]) + .bind_arguments(move |arguments, _| arguments.public_key = public_key); - test_context - .engine::() - .with_custom_strategy(strategy) - .with_custom_transaction(transaction) - .with_custom_check(SuccessfulProgramCheck) - .run(); + test_context.engine(scenario).run(); Ok(()) } diff --git a/fixtures/tests/log_level.rs b/fixtures/tests/log_level.rs index e680c71a..ed5084e2 100644 --- a/fixtures/tests/log_level.rs +++ b/fixtures/tests/log_level.rs @@ -1,4 +1,4 @@ -use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput, RequiredSignature}; +use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput}; use simplex_fixtures::artifacts::dummy_panic::DummyPanicProgram; use simplex_fixtures::artifacts::dummy_panic::derived_dummy_panic::{DummyPanicArguments, DummyPanicWitness}; @@ -31,7 +31,7 @@ fn dummy_log_level(context: simplex::TestContext) -> anyhow::Result<()> { ft.add_program_input( PartialInput::new(utxos[0].clone()), ProgramInput::new(Box::new(dummy.as_ref().clone()), DummyPanicWitness::default()), - RequiredSignature::None, + None, ); let result = signer.broadcast(&ft); diff --git a/fixtures/tests/multidep_test.rs b/fixtures/tests/multidep_test.rs index 56947db6..3521e64d 100644 --- a/fixtures/tests/multidep_test.rs +++ b/fixtures/tests/multidep_test.rs @@ -1,6 +1,6 @@ use simplex::simplicityhl::elements::Script; -use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput, RequiredSignature}; +use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput}; use simplex_fixtures::artifacts::imports::multidep::MultidepProgram; use simplex_fixtures::artifacts::imports::multidep::derived_multidep::{MultidepArguments, MultidepWitness}; @@ -40,7 +40,7 @@ fn spend_p2pk(context: &simplex::TestContext) -> anyhow::Result<()> { ft.add_program_input( PartialInput::new(utxos[0].clone()), ProgramInput::new(Box::new(program.as_ref().clone()), witness), - RequiredSignature::None, + None, ); let tx_receipt = signer.broadcast(&ft)?; diff --git a/fixtures/tests/nested_sig.rs b/fixtures/tests/nested_sig.rs index fd2a208b..842a5d72 100644 --- a/fixtures/tests/nested_sig.rs +++ b/fixtures/tests/nested_sig.rs @@ -1,6 +1,6 @@ use simplex::constants::DUMMY_SIGNATURE; use simplex::simplicityhl::elements::Script; -use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput, RequiredSignature}; +use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput, ProgramSignature}; use simplex_fixtures::artifacts::nested_sig::NestedSigProgram; use simplex_fixtures::artifacts::nested_sig::derived_nested_sig::{NestedSigArguments, NestedSigWitness}; @@ -45,7 +45,7 @@ fn spend_nested_sig( ft.add_program_input( PartialInput::new(utxos[0].clone()), ProgramInput::new(Box::new(program.as_ref().clone()), witness), - RequiredSignature::witness_with_path("INHERIT_OR_NOT", sig_path), + Some(ProgramSignature::new("INHERIT_OR_NOT").at_path(sig_path)), ); let tx_receipt = signer.broadcast(&ft)?; diff --git a/fixtures/tests/reissuance_test.rs b/fixtures/tests/reissuance_test.rs index fa407d83..01b595a7 100644 --- a/fixtures/tests/reissuance_test.rs +++ b/fixtures/tests/reissuance_test.rs @@ -3,7 +3,7 @@ use simplex::simplicityhl::elements::AssetId; use simplex::signer::Signer; use simplex::transaction::partial_input::IssuanceInput; use simplex::transaction::{ - FinalTransaction, IssuanceDetails, PartialInput, PartialOutput, RequiredSignature, TxReceipt, + FinalTransaction, IssuanceDetails, NativeSignature, PartialInput, PartialOutput, TxReceipt, }; fn make_confidential_to_bob<'a>(alice: &'a Signer, bob: &Signer, asset: AssetId) -> anyhow::Result> { @@ -31,7 +31,7 @@ fn issue_explicit_to_alice_with_reissuance<'a>( let issuance_details = ft.add_issuance_input( PartialInput::new(utxos[0].clone()), IssuanceInput::new_issuance(1000, 100, [1u8; 32]), - RequiredSignature::NativeEcdsa, + Some(NativeSignature::Ecdsa), ); ft.add_output(PartialOutput::new( @@ -75,7 +75,7 @@ fn reissue_tokens_to_bob<'a>( ft.add_issuance_input( PartialInput::new(reissuance_token_utxo), IssuanceInput::new_reissuance(reissuance_amount, issuance_details.asset_entropy.to_byte_array()), - RequiredSignature::NativeEcdsa, + Some(NativeSignature::Ecdsa), ); ft.add_output(PartialOutput::new( diff --git a/fixtures/tests/sig_message.rs b/fixtures/tests/sig_message.rs index dcf2b0eb..e7247343 100644 --- a/fixtures/tests/sig_message.rs +++ b/fixtures/tests/sig_message.rs @@ -4,7 +4,7 @@ use simplex::constants::DUMMY_SIGNATURE; use simplex::either::Either; use simplex::simplicityhl::elements::Script; use simplex::simplicityhl::simplicity::hashes::sha256; -use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput, RequiredSignature, SigMessage}; +use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput, ProgramSignature, SigMessage}; use simplex_fixtures::artifacts::sig_message::SigMessageProgram; use simplex_fixtures::artifacts::sig_message::derived_sig_message::{SigMessageArguments, SigMessageWitness}; @@ -38,7 +38,7 @@ fn fund_sig_message(context: &simplex::TestContext) -> anyhow::Result<()> { fn spend_sig_message( context: &simplex::TestContext, witness: SigMessageWitness, - required_sig: RequiredSignature, + required_sig: Option, ) -> anyhow::Result<()> { let signer = context.get_default_signer(); let provider = context.get_default_provider(); @@ -73,7 +73,7 @@ fn test_sighash_message(context: simplex::TestContext) -> anyhow::Result<()> { spend_sig_message( &context, witness, - RequiredSignature::witness_with_path("SIGNATURE", ["Left"]), + Some(ProgramSignature::new("SIGNATURE").at_path(["Left"])), ) } @@ -89,7 +89,11 @@ fn test_tagged_message(context: simplex::TestContext) -> anyhow::Result<()> { spend_sig_message( &context, witness, - RequiredSignature::witness_tagged("SIGNATURE", ["Right", "Left"], TAG), + Some( + ProgramSignature::new("SIGNATURE") + .at_path(["Right", "Left"]) + .tagged(TAG), + ), ) } @@ -110,6 +114,10 @@ fn test_custom_message(context: simplex::TestContext) -> anyhow::Result<()> { spend_sig_message( &context, witness, - RequiredSignature::witness_with_message("SIGNATURE", ["Right", "Right"], message), + Some( + ProgramSignature::new("SIGNATURE") + .at_path(["Right", "Right"]) + .with_message(message), + ), ) } diff --git a/fixtures/tests/tapleaf_test.rs b/fixtures/tests/tapleaf_test.rs index 6e560d46..e81c1e72 100644 --- a/fixtures/tests/tapleaf_test.rs +++ b/fixtures/tests/tapleaf_test.rs @@ -1,4 +1,4 @@ -use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput, RequiredSignature}; +use simplex::transaction::{FinalTransaction, PartialInput, ProgramInput}; use simplex_fixtures::artifacts::tapleaf_check::TapleafCheckProgram; use simplex_fixtures::artifacts::tapleaf_check::derived_tapleaf_check::{TapleafCheckArguments, TapleafCheckWitness}; @@ -25,7 +25,7 @@ fn program_tapleaf_test(context: simplex::TestContext) -> anyhow::Result<()> { ft.add_program_input( PartialInput::new(tapleaf_check_utxo), ProgramInput::new(Box::new(tapleaf_check.as_ref().clone()), witness), - RequiredSignature::None, + None, ); let tx_receipt = signer.broadcast(&ft)?; diff --git a/simplexup/simplexup b/simplexup/simplexup index eaeb4d50..b72bc35c 100755 --- a/simplexup/simplexup +++ b/simplexup/simplexup @@ -3,7 +3,7 @@ set -eo pipefail # NOTE: if you make modifications to this script, please increment the version number. # WARNING: the SemVer pattern: major.minor.patch must be followed as we use it to determine if the script is up to date. -SIMPLEXUP_INSTALLER_VERSION="0.0.6" +SIMPLEXUP_INSTALLER_VERSION="0.0.8" BASE_DIR=${XDG_CONFIG_HOME:-$HOME} SIMPLEX_DIR=${SIMPLEX_DIR:-"$BASE_DIR/.simplex"} @@ -11,14 +11,32 @@ SIMPLEX_VERSIONS_DIR="$SIMPLEX_DIR/versions" SIMPLEX_BIN_DIR="$SIMPLEX_DIR/bin" SIMPLEX_BIN_URL="https://raw.githubusercontent.com/BlockstreamResearch/smplx/master/simplexup/simplexup" SIMPLEX_BIN_PATH="$SIMPLEX_BIN_DIR/simplexup" -SIMPLEXUP_IGNORE_VERIFICATION=false DEFAULT_SIMPLEX_REPO="BlockstreamResearch/smplx" +SIMPLEXUP_IGNORE_VERIFICATION=false SMPLX_BIN_NAME="simplex" -DEP_BINS=() -BINS=("${SMPLX_BIN_NAME}") -HASH_NAMES=() -HASH_VALUES=() +# Every binary a release may ship +ALL_BINS=("${SMPLX_BIN_NAME}" elementsd electrs smplx-nextest simfmt) +# Written last into a version dir, so an interrupted or rejected install is never used +COMPLETE_MARKER=".complete" + +# Set by `resolve_install` +VERSION_DIR="" +# The binaries the installed version is made of +RELEASE_BINS=() +# The ones taken from the release archive +ARCHIVE_BINS=() + +# Set by `install_version`, removed on exit +WORK_DIR="" + +# Set by `fetch_attestation` +ATTESTATION_FOUND=false +ATTESTATION_BUNDLE="" +# The statement, listing each binary's name and SHA-256 +ATTESTATION_STATEMENT="" +# If the GitHub CLI also checks who signed the attestation +GH_VERIFY=false main() { need_cmd git @@ -35,7 +53,7 @@ main() { -r|--repo) shift; SIMPLEX_REPO=$1;; -i|--install) shift; SIMPLEX_VERSION=$1;; -l|--list) shift; list;; - -u|--use) shift; SIMPLEX_VERSION=$1; use;; + -u|--use) shift; use "$1";; -f|--force) SIMPLEXUP_IGNORE_VERIFICATION=true;; --arch) shift; SIMPLEX_ARCH=$1;; --platform) shift; SIMPLEX_PLATFORM=$1;; @@ -53,220 +71,131 @@ main() { # Print the banner after successfully parsing args banner - # Check if the simplexup installer is up to date, warn the user if not check_installer_up_to_date + install_version +} - # Default to Simplex repo - SIMPLEX_REPO=${SIMPLEX_REPO:-$DEFAULT_SIMPLEX_REPO} +# Installs the requested release, or the latest one. The binaries are verified before any of them +# enters the version dir, and the version is activated once they are all in. +install_version() { + resolve_install - # Install by downloading binaries - LATEST_TAG=$(latest_tag) - SIMPLEX_VERSION=$(add_v_prefix "${SIMPLEX_VERSION:-$LATEST_TAG}") + WORK_DIR="$(mktemp -d 2>/dev/null)" || err "failed to create temp dir" + trap 'rm -rf "${WORK_DIR:?}"' EXIT - # Resolve dependency binaries based on the version (populates DEP_BINS and BINS) - local trimmed_version=${SIMPLEX_VERSION#v} - resolve_dep_bins "$trimmed_version" + if fetch_attestation && [[ -z "$SIMPLEX_COMMIT" ]] && verify_bins "$VERSION_DIR"; then + say "version $SIMPLEX_VERSION already installed and verified, activating..." + touch "$VERSION_DIR/$COMPLETE_MARKER" + use "$(basename "$VERSION_DIR")" + fi - check_bins_in_use + local release_dir="$WORK_DIR/release" + download_release "$release_dir" - # Check if the requested version exists - if [[ "${SIMPLEX_VERSION}" != "${LATEST_TAG}" ]]; then - validate_tag_version "${SIMPLEX_VERSION}" "${SIMPLEX_REPO}" + if $ATTESTATION_FOUND; then + say "verifying downloaded binaries against the attestation" + verify_bins "$release_dir" verbose || err "one or more binaries failed verification" fi - say "installing simplex (version ${SIMPLEX_VERSION})" + if [[ -n "$SIMPLEX_COMMIT" ]]; then + build_from_commit "$release_dir" + fi - # Detect platform and architecture. - detect_platform_arch + store_version "$release_dir" + use "$(basename "$VERSION_DIR")" +} - # Compute the URL of the release tarball in the Simplex repository. - GITHUB_REPO_URL="https://github.com/${SIMPLEX_REPO}" - RELEASE_URL="${GITHUB_REPO_URL}/releases/download/${SIMPLEX_VERSION}/" - ATTESTATION_URL="${RELEASE_URL}simplex-${SIMPLEX_VERSION}-${PLATFORM}-${ARCHITECTURE}.attestation.txt" - BIN_ARCHIVE_URL="${RELEASE_URL}simplex-${SIMPLEX_VERSION}-${PLATFORM}-${ARCHITECTURE}.tar.gz" +# Activates installed version $1. Every binary is replaced and the ones $1 doesn't ship are removed, +# so no two versions mix. +use() { + [ -z "$1" ] && err "no version provided" - version_dir="${SIMPLEX_VERSIONS_DIR}/${SIMPLEX_VERSION}" - ensure mkdir -p "$version_dir" + local name + name=$(add_v_prefix "$1") + local dir="$SIMPLEX_VERSIONS_DIR/$name" - local BINS_TO_DOWNLOAD=("${SMPLX_BIN_NAME}" "${DEP_BINS[@]}") + [ -f "$dir/$COMPLETE_MARKER" ] || err "version $name not installed" - if [[ -n "$SIMPLEX_COMMIT" ]]; then - local author="$(echo "${SIMPLEX_REPO}" | cut -d'/' -f1 -)" - BINS_TO_DOWNLOAD=("${DEP_BINS[@]}") + check_bins_in_use "${ALL_BINS[@]}" + ensure mkdir -p "$SIMPLEX_BIN_DIR" - install_simplex_from_commit "${SIMPLEX_COMMIT}" "${author}" "${GITHUB_REPO_URL}" "${version_dir}" - say "Successfully compiled simplex from commit: ${SIMPLEX_COMMIT} in repo: ${GITHUB_REPO_URL}" - fi + for bin in "${ALL_BINS[@]}"; do + local bin_path="$SIMPLEX_BIN_DIR/$bin" - BINS_LIST_UX=$(form_str_list "${BINS_TO_DOWNLOAD[@]}") + if [ ! -f "$dir/$bin" ]; then + rm -f "${bin_path:?}" + continue + fi - # If `--force` is set, skip the SHA verification. - if [ "$SIMPLEXUP_IGNORE_VERIFICATION" = false ]; then - # Check if the version is already installed by downloading the attestation file. - say "checking if $BINS_LIST_UX for $SIMPLEX_VERSION version are already installed" + ensure cp "$dir/$bin" "$bin_path" - # Create a temporary directory to store the attestation link and artifact. - tmp_dir="$(mktemp -d 2>/dev/null)" || err "failed to create temp dir" - tmp="$tmp_dir/attestation.txt" - ensure download "$ATTESTATION_URL" "$tmp" + if [ "$bin" = "$SMPLX_BIN_NAME" ]; then + say "- $(ensure "$bin_path" -V)" + fi - # Read the first line of the attestation file to get the artifact link. - # The first line should contain the link to the attestation artifact. - attestation_artifact_link="$(head -n1 "$tmp" | tr -d '\r')" - attestation_missing=false + warn_if_shadowed "$bin" "$bin_path" + done - # If the attestation artifact link is empty or the file contains 'Not Found', - # we consider the attestation missing and skip the SHA verification. - if [ -z "$attestation_artifact_link" ] || grep -q 'Not Found' "$tmp"; then - attestation_missing=true - fi + say "done!" + exit 0 +} - # Clean up the temporary attestation file. - rm -f "$tmp" +list() { + if [ -d "$SIMPLEX_VERSIONS_DIR" ]; then + for dir in "$SIMPLEX_VERSIONS_DIR"/*; do + say "${dir##*/}" - if $attestation_missing; then - say "no attestation found for this release, skipping SHA verification" - else - say "found attestation for $SIMPLEX_VERSION version, downloading attestation artifact, checking..." - - # Download the attestation artifact JSON file. - tmp="$tmp_dir/simplex-attestation.sigstore.json" - ensure download "${attestation_artifact_link}/download" "$tmp" - - # Extract the payload from the JSON file. - payload_b64=$(awk '/"payload":/ {gsub(/[",]/, "", $2); print $2; exit}' "$tmp") - payload_json=$(printf '%s' "$payload_b64" | base64 -d 2>/dev/null || printf '%s' "$payload_b64" | base64 -D) - - # Extract the names and hashes from the payload JSON. - # The payload is expected to be a JSON array of objects with "name" and "sha256" fields. - while read -r name_line && read -r sha_line; do - name=$(echo "$name_line" | sed -nE 's/.*"name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p') - sha=$(echo "$sha_line" | sed -nE 's/.*"sha256"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p') - - if [ -n "$name" ] && [ -n "$sha" ]; then - HASH_NAMES+=("$name") - HASH_VALUES+=("$sha") - fi - done < <(echo "$payload_json" | tr '{}' '\n' | grep -E '"name"|sha256') - - # Clean up the temporary attestation artifact. - # The hashes are now stored in the HASHES associative array. - rm -f "$tmp" - - # Check if the binaries are already installed and match the expected hashes. - # If they do, skip the download. - all_match=true - - for bin in "${BINS_TO_DOWNLOAD[@]}"; do - expected="" - - for i in "${!HASH_NAMES[@]}"; do - if [ "${HASH_NAMES[$i]}" = "$bin" ]; then - expected="${HASH_VALUES[$i]}" - break - fi - done - - path="$version_dir/$bin" - - if [ -z "$expected" ] || [ ! -x "$path" ]; then - all_match=false - break - fi - - actual=$(compute_sha256 "$path") - - if [ "$actual" != "$expected" ]; then - all_match=false - break - fi - done - - if $all_match; then - say "version $SIMPLEX_VERSION already installed and verified (for $BINS_LIST_UX), activating..." - use - say "done!" - exit 0 + if [ -f "$dir/$COMPLETE_MARKER" ]; then + say "- $(ensure "$dir/$SMPLX_BIN_NAME" -V)" + else + say "- incomplete install" fi - fi - # If we reach here, we need to download the binaries. - say "binaries not found or do not match expected hashes, downloading new binaries" + printf "\n" + done + else + say "- $(ensure "$SIMPLEX_BIN_DIR/$SMPLX_BIN_NAME" -V)" fi - # Download and extract the binaries archive - say "downloading $BINS_LIST_UX for $SIMPLEX_VERSION version" + exit 0 +} - tmp_dir="$(mktemp -d 2>/dev/null)" || err "failed to create temp dir" - tmp_tar="$tmp_dir/simplex.tar.gz" +update() { + say "updating simplexup..." - ensure download "$BIN_ARCHIVE_URL" "$tmp_tar" - # Make sure it's a valid tar archive. - ensure tar tf "$tmp_tar" 1> /dev/null - ensure tar -C "$tmp_dir" -xvf "$tmp_tar" + local current_version="$SIMPLEXUP_INSTALLER_VERSION" + local tmp_file + tmp_file="$(mktemp)" - for bin in "${BINS_TO_DOWNLOAD[@]}"; do - mv "$tmp_dir/$bin" "$version_dir/$bin" - done - rm -rf "$tmp_dir" + ensure download "$SIMPLEX_BIN_URL" "$tmp_file" - if [ "$SIMPLEXUP_IGNORE_VERIFICATION" = true ]; then - say "skipped SHA verification for downloaded binaries due to --force flag" - else - # Verify the downloaded binaries against the attestation file. - # If the attestation file was not found or is empty, we skip the verification. - if $attestation_missing; then - say "no attestation found for these binaries, skipping SHA verification for downloaded binaries" - else - say "verifying downloaded binaries against the attestation file" - - failed=false - - for bin in "${BINS_TO_DOWNLOAD[@]}"; do - expected="" - - for i in "${!HASH_NAMES[@]}"; do - if [ "${HASH_NAMES[$i]}" = "$bin" ]; then - expected="${HASH_VALUES[$i]}" - break - fi - done - - path="$SIMPLEX_VERSIONS_DIR/$SIMPLEX_VERSION/$bin" - - if [ -z "$expected" ]; then - say "no expected hash for $bin" - failed=true - continue - fi - - if [ ! -x "$path" ]; then - say "binary $bin not found at $path" - failed=true - continue - fi - - actual=$(compute_sha256 "$path") - - if [ "$actual" != "$expected" ]; then - say "$bin hash verification failed:" - say " expected: $expected" - say " actual: $actual" - failed=true - else - say "$bin verified ✓" - fi - done - - if $failed; then - err "one or more binaries failed post-installation verification" - fi - fi + local new_version + new_version=$(extract_installer_version < "$tmp_file" || true) + + # An empty or invalid version is never installed + if [ -z "$new_version" ]; then + warn "could not determine the new simplexup version" + rm -f "$tmp_file" + exit 0 fi - use + # Neither a downgrade nor a reinstall of the same version + if ! version_gt "$new_version" "$current_version"; then + say "simplexup is already up to date (installed: $current_version, remote: $new_version)" + rm -f "$tmp_file" + exit 0 + fi - say "done!" + ensure mv "$tmp_file" "$SIMPLEX_BIN_PATH" + ensure chmod +x "$SIMPLEX_BIN_PATH" + + say "updated simplexup: $current_version → $new_version" + exit 0 +} + +version() { + say "$SIMPLEXUP_INSTALLER_VERSION" + exit 0 } usage() { @@ -283,7 +212,7 @@ USAGE: OPTIONS: -h, --help Print help information -v, --version Print the version of simplexup - -C, --commit, Install specific simplex revision from repository + -C, --commit Install specific Simplex revision from repository -U, --update Update simplexup to the latest version -i, --install Install a specific version from built binaries -l, --list List versions installed from built binaries @@ -295,172 +224,252 @@ OPTIONS: EOF } -version() { - say "$SIMPLEXUP_INSTALLER_VERSION" - exit 0 -} - -update() { - say "updating simplexup..." - - current_version="$SIMPLEXUP_INSTALLER_VERSION" +# Settles the version to install, where its binaries come from, and where they go. +# A commit build gets its own version dir, so a release's binaries are never replaced. +resolve_install() { + SIMPLEX_REPO=${SIMPLEX_REPO:-$DEFAULT_SIMPLEX_REPO} - # Download the new version. - tmp_file="$(mktemp)" - ensure download "$SIMPLEX_BIN_URL" "$tmp_file" + local latest_tag + latest_tag=$(latest_tag) + SIMPLEX_VERSION=$(add_v_prefix "${SIMPLEX_VERSION:-$latest_tag}") - # Extract new version from downloaded file. - new_version=$(cat "$tmp_file" | extract_installer_version) + set_release_bins "${SIMPLEX_VERSION#v}" + check_bins_in_use "${RELEASE_BINS[@]}" - # If the new version could not be determined, exit gracefully. - # This prevents from upgrading to an empty or invalid version. - if [ -z "$new_version" ]; then - warn "could not determine new simplexup version. Exiting." - rm -f "$tmp_file" - exit 0 + if [[ "$SIMPLEX_VERSION" != "$latest_tag" ]]; then + validate_tag_version fi - # If the new version is not greater than the current version, skip the update. - # This is to prevent downgrades or unnecessary updates. - if ! version_gt "$new_version" "$current_version"; then - say "simplexup is already up to date (installed: $current_version, remote: $new_version)." - rm -f "$tmp_file" - exit 0 - fi + say "installing simplex (version ${SIMPLEX_VERSION})" - # Overwrite existing simplexup - ensure mv "$tmp_file" "$SIMPLEX_BIN_PATH" - ensure chmod +x "$SIMPLEX_BIN_PATH" + detect_platform_arch - say "successfully updated simplexup: $current_version → $new_version" - exit 0 -} + GITHUB_REPO_URL="https://github.com/${SIMPLEX_REPO}" -list() { - if [ -d "$SIMPLEX_VERSIONS_DIR" ]; then - for VERSION in $SIMPLEX_VERSIONS_DIR/*; do - say "${VERSION##*/}" - - for bin in "${BINS[@]}"; do - case $bin in - simplex) - bin_path="$VERSION/$bin" - say "- $(ensure "$bin_path" -V)" - ;; - esac - done + local release_url="${GITHUB_REPO_URL}/releases/download/${SIMPLEX_VERSION}/simplex-${SIMPLEX_VERSION}-${PLATFORM}-${ARCHITECTURE}" + ATTESTATION_URL="${release_url}.attestation.txt" + BIN_ARCHIVE_URL="${release_url}.tar.gz" - printf "\n" - done + if [[ -n "$SIMPLEX_COMMIT" ]]; then + VERSION_DIR="${SIMPLEX_VERSIONS_DIR}/${SIMPLEX_REPO%%/*}-${SIMPLEX_COMMIT}" + ARCHIVE_BINS=("${RELEASE_BINS[@]:1}") else - for bin in "${BINS[@]}"; do - case $bin in - simplex) - bin_path="$SIMPLEX_BIN_DIR/$bin" - say "- $(ensure "$bin_path" -V)" - ;; - esac - done + VERSION_DIR="${SIMPLEX_VERSIONS_DIR}/${SIMPLEX_VERSION}" + ARCHIVE_BINS=("${RELEASE_BINS[@]}") fi - exit 0 } -use() { - [ -z "$SIMPLEX_VERSION" ] && err "no version provided" - - SIMPLEX_VERSION=$(add_v_prefix "${SIMPLEX_VERSION}") +# Loads the release's attestation. Returns non-zero if nothing is loaded or when `--force` skips verification, +# or if the release has no attestation. +fetch_attestation() { + if [ "$SIMPLEXUP_IGNORE_VERIFICATION" = true ]; then + say "skipping SHA verification due to --force flag" + return 1 + fi - SIMPLEX_VERSION_DIR="$SIMPLEX_VERSIONS_DIR/$SIMPLEX_VERSION" + check_cmd sha256sum || check_cmd shasum || err "need 'sha256sum' or 'shasum' (command not found)" - if [ -d "$SIMPLEX_VERSION_DIR" ]; then - check_bins_in_use - ensure mkdir -p "$SIMPLEX_BIN_DIR" + say "checking the attestation for $SIMPLEX_VERSION version" - for bin in "${BINS[@]}"; do - bin_path="$SIMPLEX_BIN_DIR/$bin" - cp "$SIMPLEX_VERSION_DIR/$bin" "$bin_path" + # The first line of the attestation file links to the attestation bundle + local link_file="$WORK_DIR/attestation.txt" + download "$ATTESTATION_URL" "$link_file" || : > "$link_file" - # Print usage msg - case $bin in - simplex) - say "- $(ensure "$bin_path" -V)" - ;; - esac + local bundle_link + bundle_link="$(head -n1 "$link_file" | tr -d '\r')" - # Check if the default path of the binary is not in SIMPLEX_BIN_DIR - which_path="$(command -v "$bin" || true)" + if [ -z "$bundle_link" ] || grep -q 'Not Found' "$link_file"; then + say "no attestation found for this release, skipping SHA verification" + return 1 + fi - if [ -n "$which_path" ] && [ "$which_path" != "$bin_path" ]; then - warn "" - cat 1>&2 </dev/null; then + GH_VERIFY=true else - err "version $SIMPLEX_VERSION not installed" + say "gh not found or not logged in, checking the attested hashes only" fi } -say() { - printf "simplexup: %s\n" "$1" +# Downloads the release archive and extracts its binaries into $1 +download_release() { + local out_dir=$1 + local archive="$out_dir/simplex.tar.gz" + + say "downloading $(form_str_list "${ARCHIVE_BINS[@]}") for $SIMPLEX_VERSION version" + + ensure mkdir -p "$out_dir" + ensure download "$BIN_ARCHIVE_URL" "$archive" + # Make sure it's a valid tar archive. + ensure tar tf "$archive" 1> /dev/null + ensure tar -C "$out_dir" -xf "$archive" } -warn() { - say "warning: ${1}" >&2 +# Checks the archive binaries in dir $1 against the attestation. With $2 set, reports on each of them. +verify_bins() { + local dir=$1 + local verbose=$2 + local verified=true + + for bin in "${ARCHIVE_BINS[@]}"; do + local path="$dir/$bin" + local expected actual + expected=$(attested_hash "$bin") + + if [ -z "$expected" ]; then + [ -n "$verbose" ] && say "no expected hash for $bin" + verified=false + continue + fi + + if [ ! -x "$path" ]; then + [ -n "$verbose" ] && say "binary $bin not found at $path" + verified=false + continue + fi + + actual=$(compute_sha256 "$path") + + if [ "$actual" != "$expected" ]; then + if [ -n "$verbose" ]; then + say "$bin hash verification failed:" + say " expected: $expected" + say " actual: $actual" + fi + verified=false + elif $GH_VERIFY && ! gh_verify "$path"; then + [ -n "$verbose" ] && say "$bin attestation is not signed by the $SIMPLEX_REPO release workflow" + verified=false + elif [ -n "$verbose" ]; then + say "$bin verified ✓" + fi + done + + $verified } -err() { - say "$1" >&2 - exit 1 +# Checks with the GitHub CLI that the attestation bundle attests binary $1 and that the release workflow signed the bundle +gh_verify() { + gh attestation verify "$1" \ + --bundle "$ATTESTATION_BUNDLE" \ + --repo "$SIMPLEX_REPO" \ + --signer-workflow "$SIMPLEX_REPO/.github/workflows/release.yml" \ + &>/dev/null } -tolower() { - echo "$1" | awk '{print tolower($0)}' +# Builds simplex at SIMPLEX_COMMIT into dir $1, in place of the release's +build_from_commit() { + need_cmd cargo + + local out_dir=$1 + local src_dir="$WORK_DIR/source" + + say "building simplex from commit ${SIMPLEX_COMMIT} of ${GITHUB_REPO_URL}" + + ensure git clone --revision="${SIMPLEX_COMMIT}" --depth=1 "${GITHUB_REPO_URL}" "${src_dir}" + ensure cargo build --release --manifest-path "${src_dir}/Cargo.toml" + ensure mv "${src_dir}/target/release/${SMPLX_BIN_NAME}" "${out_dir}/${SMPLX_BIN_NAME}" } -compute_sha256() { - shasum -a 256 "$1" | awk '{print $1}' +# Moves the binaries from dir $1 into the version dir, which is marked complete once they are all in +store_version() { + local from_dir=$1 + + ensure mkdir -p "$VERSION_DIR" + rm -f "${VERSION_DIR:?}/${COMPLETE_MARKER:?}" + + for bin in "${RELEASE_BINS[@]}"; do + ensure mv "$from_dir/$bin" "$VERSION_DIR/$bin" + done + + touch "$VERSION_DIR/$COMPLETE_MARKER" } -need_cmd() { - if ! check_cmd "$1"; then - err "need '$1' (command not found)" +check_installer_up_to_date() { + say "checking if simplexup is up to date..." + + local remote_version + remote_version=$(curl -fsSL "$SIMPLEX_BIN_URL" | extract_installer_version || true) + + if [ -z "$remote_version" ]; then + warn "could not determine the remote simplexup version, skipping the check" + return 0 fi -} -check_cmd() { - command -v "$1" &>/dev/null -} + if version_gt "$remote_version" "$SIMPLEXUP_INSTALLER_VERSION"; then + printf ' +Your installation of simplexup is out of date. -latest_tag() { - # Fetch the latest Simplex release tag name - LATEST_TAG=$(curl -fsSL "https://api.github.com/repos/$SIMPLEX_REPO/releases/latest" | jq -r .tag_name) +Installed: %s → Latest: %s - echo $LATEST_TAG +To update, run: + + simplexup --update + +Updating is highly recommended as it gives you access to the latest features and bug fixes. + +' "$SIMPLEXUP_INSTALLER_VERSION" "$remote_version" >&2 + else + say "simplexup is up to date" + fi } validate_tag_version() { - local current_version=$1 - local simplex_repo=$2 + say "checking if version ${SIMPLEX_VERSION} exists..." - say "Checking if version ${current_version} exists..." + if ! curl -IfsSL "https://github.com/${SIMPLEX_REPO}/releases/tag/${SIMPLEX_VERSION}" >/dev/null; then + err "version ${SIMPLEX_VERSION} not found in ${SIMPLEX_REPO} releases" + fi +} - if ! curl -IfsSL "https://github.com/${simplex_repo}/releases/tag/${current_version}" >/dev/null; then - err "Error: version '${current_version}' not found in ${simplex_repo} releases." +# Fails while one of the binaries $@ is running +check_bins_in_use() { + if ! check_cmd pgrep; then + warn "make sure no simplex process runs during the install" + return 0 fi + + for bin in "$@"; do + if pgrep -x "$bin" >/dev/null; then + err "'$bin' is running, stop the process and try again" + fi + done +} + +# Warns when another binary named $1 takes precedence over the installed $2 in PATH +warn_if_shadowed() { + local bin=$1 + local bin_path=$2 + local which_path + which_path="$(command -v "$bin" || true)" + + if [ -z "$which_path" ] || [ "$which_path" = "$bin_path" ]; then + return 0 + fi + + warn "" + cat 1>&2 <&2 - else - say "simplexup is up to date." + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + version="v${version}" fi + + echo "${version}" } # Compares two version strings in the format "major.minor.patch". @@ -553,117 +565,84 @@ EOF return 1 } -check_bins_in_use() { - if check_cmd pgrep; then - for bin in "${BINS[@]}"; do - if pgrep -x "$bin" >/dev/null; then - err "Error: '$bin' is currently running. Please stop the process and try again." - fi - done - else - warn "Make sure no simplex process is running during the install process!" - fi +# Extracts the simplexup installer version from standard input. +extract_installer_version() { + grep -Eo 'SIMPLEXUP_INSTALLER_VERSION="[0-9]+\.[0-9]+\.[0-9]+"' | cut -d'"' -f2 } -# Run a command that should never fail. If the command fails execution -# will immediately terminate with an error showing the failing command. -ensure() { - if ! "$@"; then err "command failed: $*"; fi +compute_sha256() { + if check_cmd sha256sum; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi } -# Downloads $1 into $2 or stdout +# Downloads $1 into $2 or stdout, failing on an HTTP error download() { if [ -n "$2" ]; then - # output into $2 if check_cmd curl; then - curl -#o "$2" -L "$1" + curl -f -# -L -o "$2" "$1" else wget --show-progress -qO "$2" "$1" fi else - # output to stdout if check_cmd curl; then - curl -#L "$1" + curl -f -# -L "$1" else wget --show-progress -qO- "$1" fi fi } -install_simplex_from_commit() { - need_cmd cargo - - local commit=$1 - local author=$2 - local repo_url=$3 - local out_dir=$4 - - local simplex_version="simplex-${author}-${commit}" - local tmp_dir="$(mktemp -d 2>/dev/null)" || err "failed to create temp dir" - ensure git clone --revision="${commit}" --depth=1 "${repo_url}" "${tmp_dir}" - - say "Installing Simplex version $simplex_version" - - ensure cargo build --release --manifest-path "${tmp_dir}/Cargo.toml" - ensure mv "${tmp_dir}/target/release/${SMPLX_BIN_NAME}" "${out_dir}/${SMPLX_BIN_NAME}" - - say "Removing temporary directory..." - rm -rf "${tmp_dir}" -} - -# Form a comma separated list with 'and' for user experience +# Joins $@ into "a, b and c" form_str_list() { - local STR_LIST=("$@") + local items=("$@") + local count=${#items[@]} - local LAST_EL="${STR_LIST[*]: -1}" - local REST_ELS=("${STR_LIST[@]:0:${#STR_LIST[@]}-1}") - local EL_STRING="" + if [ "$count" -le 1 ]; then + echo "${items[*]}" + return 0 + fi - if [ ${#STR_LIST[@]} -eq 1 ]; then - EL_STRING="${STR_LIST[0]}" - elif [ ${#STR_LIST[@]} -eq 2 ]; then - EL_STRING="${STR_LIST[0]} and ${STR_LIST[1]}" - else - SAVE_IFS="$IFS" - IFS="," - EL_STRING="${REST_ELS[*]}" - IFS="$SAVE_IFS" + local head=("${items[@]:0:count-1}") + local joined + joined=$(IFS=","; echo "${head[*]}") - EL_STRING="${EL_STRING//,/, } and ${LAST_EL}" - fi + echo "${joined//,/, } and ${items[count-1]}" +} - echo "$EL_STRING" +tolower() { + echo "$1" | awk '{print tolower($0)}' } -# Resolves and sets variables (DEP_BINS, BINS) with an appropriate values -resolve_dep_bins() { - local version="${1}" +say() { + printf "simplexup: %s\n" "$1" +} - if [ -z "$version" ]; then - warn "No version specified for resolving the current binary list." - return 0 - fi +warn() { + say "warning: ${1}" >&2 +} - # The first version with `smplx-nextest` binary - local simplex_turning_point="0.0.6" +err() { + say "$1" >&2 + exit 1 +} - if version_gt "${simplex_turning_point}" "$version"; then - DEP_BINS=(elementsd electrs) - else - DEP_BINS=(elementsd electrs smplx-nextest) +need_cmd() { + if ! check_cmd "$1"; then + err "need '$1' (command not found)" fi - - BINS=("${BINS[@]}" "${DEP_BINS[@]}") } -add_v_prefix() { - local version="${1}" - - if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - version="v${version}" - fi +check_cmd() { + command -v "$1" &>/dev/null +} - echo "${version}" +# Run a command that should never fail. If the command fails execution +# will immediately terminate with an error showing the failing command. +ensure() { + if ! "$@"; then err "command failed: $*"; fi } # Banner prompt for Simplex @@ -682,7 +661,7 @@ banner() { _ :_:..\ ||||| |_| _ :..\ ||| _ .\ || A blazingly-fast, ux-first - - \ | simplicity development framework + - \ | Simplicity development framework -. -~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-