diff --git a/crates/test/src/context.rs b/crates/test/src/context.rs index fedc97cf..26a6b2fc 100644 --- a/crates/test/src/context.rs +++ b/crates/test/src/context.rs @@ -277,7 +277,8 @@ impl TestContext { &self.mode.proptest_config } - /// Builds a fuzz engine with a fresh copy of the default signer, so it can be called repeatedly. + /// Builds a fuzz engine for `scenario` with this context's proptest configuration and a fresh + /// copy of the default signer, so it can be called repeatedly. pub fn engine(&self, scenario: Scenario) -> SimplexFuzzEngine { SimplexFuzzEngine::new( self.mode.proptest_config.clone(), diff --git a/crates/test/src/fuzz/case.rs b/crates/test/src/fuzz/case.rs index 8626f830..679f2192 100644 --- a/crates/test/src/fuzz/case.rs +++ b/crates/test/src/fuzz/case.rs @@ -11,15 +11,25 @@ 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. +/// What a spec expects to happen to a case's spend. +/// +/// The spend is accepted when every program input of the transaction accepts. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Expect { + /// Every program input must accept. A rejection is a liveness finding. Accept, + /// Some program input must reject. Acceptance is an unsoundness finding. Reject, + /// Either outcome is fine. A run where the spec returns this for every case fails. Any, } /// Read-only view of one fuzz case, given to bindings while it is built and to the spec before it executes. +/// +/// The transaction getters return what the program's jets read, so a spec can compute the +/// contract's expected decision the way the contract does. The exception is an output with a +/// blinding key: `output_asset` and `output_amount` return its explicit values, while the jets +/// see commitments. pub struct FuzzCase<'a> { scenario: &'a Scenario, programs: &'a [Program], @@ -91,6 +101,8 @@ impl<'a> FuzzCase<'a> { self.map.new_output_position(old_index) } + /// The SHA-256 hash of the script of the output input `index` spends. + /// /// # Panics /// Panics when the transaction has no input `index`. pub fn input_script_hash(&self, index: usize) -> [u8; 32] { @@ -129,18 +141,24 @@ impl<'a> FuzzCase<'a> { .unwrap_or(self.transaction.sequence()) } + /// The SHA-256 hash of the script of output `index`. + /// /// # 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) } + /// The asset of output `index`. + /// /// # 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() } + /// The amount of output `index`. + /// /// # Panics /// Panics when the transaction has no output `index`. pub fn output_amount(&self, index: usize) -> u64 { @@ -157,6 +175,7 @@ impl<'a> FuzzCase<'a> { self.read_lock(|locktime| locktime >= LOCK_TIME_THRESHOLD) } + /// The network the case is built for. pub fn network(&self) -> &SimplicityNetwork { self.signer.get_network() } diff --git a/crates/test/src/fuzz/core.rs b/crates/test/src/fuzz/core.rs index e199f1a6..884d88f4 100644 --- a/crates/test/src/fuzz/core.rs +++ b/crates/test/src/fuzz/core.rs @@ -31,18 +31,32 @@ enum CaseFailure { Finding(String), } +/// Generates and checks the cases of a [`Scenario`]. Usually created with +/// `TestContext::::engine`. +/// +/// By default, every spend is expected to be accepted, the transaction isn't mutated, and the +/// lock and authorization checks run on every accepted case. pub struct SimplexFuzzEngine { + /// The proptest configuration: the number of cases, the seed, and failure persistence. pub config: ProptestConfig, + /// The signer that signs every case. pub signer: Signer, + /// The transaction and programs being fuzzed. pub scenario: Scenario, + /// What each case is expected to do. See [`SimplexFuzzEngine::spec`]. pub spec: Box, + /// The mutations cases draw from, `None` for unmutated cases. pub mutations: Option, + /// Whether a run passes without any accepted case. pub allow_unspendable: bool, + /// Whether to run the lock check on accepted cases. pub check_lock: bool, + /// Whether to run the authorization check on accepted cases. pub check_auth: bool, } impl SimplexFuzzEngine { + /// Creates an engine for `scenario` with the default settings. pub fn new(config: ProptestConfig, signer: Signer, scenario: Scenario) -> SimplexFuzzEngine { SimplexFuzzEngine { config, @@ -56,6 +70,7 @@ impl SimplexFuzzEngine { } } + /// Replaces the proptest configuration, for example to change the number of cases. #[must_use] pub fn with_custom_config(mut self, config: ProptestConfig) -> Self { self.config = config; @@ -63,6 +78,10 @@ impl SimplexFuzzEngine { self } + /// Replaces the signer. + /// + /// The signer must not use the attacker's mnemonic, which the smart checks use, or `run` + /// panics. #[must_use] pub fn with_custom_signer(mut self, signer: Signer) -> Self { self.signer = signer; @@ -71,6 +90,10 @@ impl SimplexFuzzEngine { } /// Sets what should happen to each spend. Without a spec, every spend is expected to be accepted. + /// + /// The spec sees the case before it runs and returns an [`Expect`]. It isn't called for a + /// case that violates a witness requirement, which must be rejected. A panic in the spec is + /// reported as a broken setup. #[must_use] pub fn spec(mut self, spec: impl Fn(&FuzzCase<'_>) -> Expect + 'static) -> Self { self.spec = Box::new(spec); @@ -78,7 +101,8 @@ impl SimplexFuzzEngine { self } - /// Mutates the transaction in half the cases. The spec judges the mutated transaction. + /// Mutates the transaction, using the kinds `mutations` enables, in half of the cases that + /// don't violate a witness requirement. The spec judges the mutated transaction. #[must_use] pub fn mutations(mut self, mutations: Mutations) -> Self { self.mutations = Some(mutations); @@ -110,6 +134,12 @@ impl SimplexFuzzEngine { self } + /// Runs the cases and prints a summary to standard error. + /// + /// # Panics + /// Panics on the first finding, with the case's programs, values and mutations; if no case + /// was accepted, unless [`SimplexFuzzEngine::allow_unspendable`] was called; if the spec + /// returned [`Expect::Any`] for every case; and if the setup is broken. pub fn run(self) { let strategy = match self .scenario diff --git a/crates/test/src/fuzz/mod.rs b/crates/test/src/fuzz/mod.rs index 038e1efe..2258b0f3 100644 --- a/crates/test/src/fuzz/mod.rs +++ b/crates/test/src/fuzz/mod.rs @@ -1,3 +1,52 @@ +//! Property testing of Simplicity contracts, without a network. +//! +//! A fuzz test describes a transaction and the programs in it as a [`Scenario`], and states what +//! should happen to each spend. [`SimplexFuzzEngine`] then generates cases: for each, it draws +//! arguments, witnesses and storage for every program, optionally mutates the transaction, signs +//! it with the test's signer, and finalizes every program input locally, which checks the witness +//! budget too. It compares the result against the expectation and, for an accepted spend, runs +//! the lock and authorization checks. +//! +//! ```ignore +//! #[simplex::fuzz] +//! fn p2pk_fuzz(context: TestContext) { +//! let public_key = context.get_default_signer().get_schnorr_public_key().serialize(); +//! let program = P2pkProgram::new(P2pkArguments { public_key }).as_ref().clone(); +//! +//! let mut transaction = FinalTransaction::new(); +//! transaction.add_program_input( +//! PartialInput::default(), +//! ProgramInput::new(Box::new(program), P2pkWitness::default()), +//! Some(ProgramSignature::new("SIGNATURE")), +//! ); +//! +//! let mut scenario = Scenario::new(transaction); +//! scenario +//! .add::("p2pk") +//! .spends(0) +//! .bind_arguments(move |arguments, _| arguments.public_key = public_key); +//! +//! context.engine(scenario).run(); +//! } +//! ``` +//! +//! A run fails with one of these findings: +//! +//! - **unsound**: the contract accepted a spend that the spec rejects, or that violates a +//! witness requirement. +//! - **liveness**: the contract rejected a spend that the spec accepts. +//! - **lock bypass**: an accepted spend is still accepted after an output the program locks is +//! redirected to an attacker. +//! - **authorization bypass**: an accepted spend is still accepted with an attacker's +//! signature, or with a signature made for another transaction. +//! +//! It also fails if no case was accepted, if the spec returned [`Expect::Any`] for every case, or +//! if the setup is broken, for example if the programs don't fit the transaction. A broken setup +//! says so in its message, and doesn't mean anything about the contract. +//! +//! Failing cases aren't shrunk. The number of cases, the seed and the network come from the +//! `[test.fuzz]` section of `Simplex.toml`. + mod case; mod common; mod core; diff --git a/crates/test/src/fuzz/mutations/core.rs b/crates/test/src/fuzz/mutations/core.rs index f6195f05..90a12f39 100644 --- a/crates/test/src/fuzz/mutations/core.rs +++ b/crates/test/src/fuzz/mutations/core.rs @@ -1,4 +1,7 @@ -/// Which mutations a run draws. +/// Which kinds of [`Mutation`](crate::fuzz::Mutation) a run draws. +/// +/// `Mutations::default()` enables amounts, assets and timelocks. Layout changes, and mutating +/// UTXOs that no program spends or locks, are off by default. #[derive(Clone, Debug)] pub struct Mutations { pub(crate) amounts: bool, diff --git a/crates/test/src/fuzz/program.rs b/crates/test/src/fuzz/program.rs index 27c2e454..ef98539b 100644 --- a/crates/test/src/fuzz/program.rs +++ b/crates/test/src/fuzz/program.rs @@ -82,10 +82,12 @@ impl<'a, P: FuzzProgramTrait> FuzzProgram<'a, P> { } } + /// The name the program was added with. pub fn name(&self) -> &str { self.program_scenario.name() } + /// The program's arguments, as generated and bound. pub fn arguments(&self) -> &P::Arguments { &self.arguments } @@ -100,10 +102,13 @@ impl<'a, P: FuzzProgramTrait> FuzzProgram<'a, P> { .unwrap_or_else(|| panic!("`{}` spends no input, so it has no witness", self.name())) } + /// The program's arguments, as [`Arguments`]. pub fn raw_arguments(&self) -> &Arguments { self.program.get_arguments() } + /// The witness, as [`WitnessValues`]. + /// /// # Panics /// Panics when the program spends no input. pub fn raw_witness(&self) -> &WitnessValues { @@ -111,6 +116,7 @@ impl<'a, P: FuzzProgramTrait> FuzzProgram<'a, P> { .unwrap_or_else(|| panic!("`{}` spends no input, so it has no witness", self.name())) } + /// The compiled program. pub fn program(&self) -> &Program { self.program } @@ -124,14 +130,17 @@ impl<'a, P: FuzzProgramTrait> FuzzProgram<'a, P> { .collect() } + /// The script of the program's address on the case's network. pub fn script_pubkey(&self) -> Script { self.program.get_script_pubkey(self.network) } + /// The SHA-256 hash of [`FuzzProgram::script_pubkey`]. pub fn script_hash(&self) -> [u8; 32] { self.program.get_script_hash(self.network) } + /// The program's CMR. pub fn cmr(&self) -> [u8; 32] { self.program.get_cmr() } diff --git a/crates/test/src/fuzz/program_scenario.rs b/crates/test/src/fuzz/program_scenario.rs index 5bf48d6b..93a64bca 100644 --- a/crates/test/src/fuzz/program_scenario.rs +++ b/crates/test/src/fuzz/program_scenario.rs @@ -20,7 +20,8 @@ use crate::fuzz::witness_path::WitnessPath; type Binding = Box) -> T>; type Transition = Box) -> Vec<[u8; 32]>>; -/// A builder to configure what each FuzzProgram does. +/// Configures one program of a [`Scenario`](crate::fuzz::Scenario), as returned by +/// `Scenario::add`. pub struct FuzzProgramScenarioBuilder<'a, P> { program_scenario: &'a mut FuzzProgramScenario, _program: PhantomData P>, @@ -186,8 +187,11 @@ impl<'a, P: FuzzProgramTrait> FuzzProgramScenarioBuilder<'a, P> { self } - /// Specifies the spending path of the program to fuzz. The path must exist in witness - /// and end with "Left" or "Right". + /// Fuzzes only the spending path at `path` in witness field `name`: every generated witness + /// takes those `Either` branches. + /// + /// The steps are those of [`ProgramSignature::at_path`](smplx_sdk::transaction::ProgramSignature::at_path), + /// and the last step must be `"Left"` or `"Right"`. pub fn spending_path(self, name: &str, path: I) -> Self where I: IntoIterator, @@ -267,7 +271,9 @@ impl<'a, P: FuzzProgramTrait> FuzzProgramScenarioBuilder<'a, P> { } /// Sets the only witness values the contract accepts, after the bindings. - /// Required witness is fuzzed and the contract should reject the alternatives. + /// + /// In about half the cases, the engine keeps a generated value that differs from a required + /// one instead. The contract must reject those cases, and the spec isn't asked about them. pub fn requires_witness(self, require: impl Fn(&mut P::Witness, &FuzzCase<'_>) + 'static) -> Self { self.program_scenario .witness_requirements diff --git a/crates/test/src/fuzz/program_strategy/core.rs b/crates/test/src/fuzz/program_strategy/core.rs index 3edf38a8..d716582d 100644 --- a/crates/test/src/fuzz/program_strategy/core.rs +++ b/crates/test/src/fuzz/program_strategy/core.rs @@ -7,7 +7,9 @@ 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 { + /// The program's arguments. pub arguments: Arguments, + /// The witness of the inputs the program spends. pub witness: WitnessValues, /// One 32-byte value per storage slot. pub storage: Vec<[u8; 32]>, diff --git a/crates/test/src/fuzz/scenario.rs b/crates/test/src/fuzz/scenario.rs index 8280d03b..f9055714 100644 --- a/crates/test/src/fuzz/scenario.rs +++ b/crates/test/src/fuzz/scenario.rs @@ -18,6 +18,13 @@ use crate::fuzz::requirement::{RequiredValue, Requirements}; use crate::fuzz::scenario_strategy::{CaseChange, CaseValues, CombinedStrategy}; /// A transaction and the programs being fuzzed. +/// +/// Assemble the transaction as for a real spend, with placeholder UTXOs such as +/// `PartialInput::default()`, then [`add`](Scenario::add) each program and say which inputs it +/// spends and which outputs it locks. Each case replaces those inputs' and outputs' scripts with +/// the program's, and those inputs' witnesses with generated ones. +/// +/// `Scenario::default()` is a transaction with one default input. pub struct Scenario { transaction: FinalTransaction, program_scenarios: Vec, @@ -32,6 +39,10 @@ impl Scenario { } } + /// Adds a program of type `P`, named `name`, and returns a builder to configure it. + /// + /// Names must be unique. At least one program must spend an input, and an input or output + /// can belong to only one program. pub fn add(&mut self, name: impl Into) -> FuzzProgramScenarioBuilder<'_, P> { self.program_scenarios.push(FuzzProgramScenario::new::

(name.into())); diff --git a/crates/test/src/fuzz/utils.rs b/crates/test/src/fuzz/utils.rs index 8a9d1e86..bda0819b 100644 --- a/crates/test/src/fuzz/utils.rs +++ b/crates/test/src/fuzz/utils.rs @@ -9,10 +9,12 @@ use rand::{Rng, RngExt}; use smplx_sdk::program::ProgramSchema; +/// Random arguments for program `P`, each value drawn as [`generate_random_value_by_ty`] draws it. pub fn random_arguments(rng: &mut R) -> Arguments { Arguments::from_map(random_fields(&P::argument_types(), rng)) } +/// A random witness for program `P`, each value drawn as [`generate_random_value_by_ty`] draws it. pub fn random_witness(rng: &mut R) -> WitnessValues { WitnessValues::from_map(random_fields(&P::witness_types(), rng)) } @@ -27,6 +29,11 @@ pub(crate) fn random_fields( .collect() } +/// A random value of type `ty`. +/// +/// Integers and booleans are uniform. Each `Either` branch and each `Option` variant is picked +/// with equal probability, so an `Option` is `None` half the time, and a list's length is uniform +/// over the lengths it allows. 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::() {