Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/test/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,8 @@ impl TestContext<FuzzMode> {
&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(),
Expand Down
21 changes: 20 additions & 1 deletion crates/test/src/fuzz/case.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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] {
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
}
Expand Down
32 changes: 31 additions & 1 deletion crates/test/src/fuzz/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,32 @@ enum CaseFailure {
Finding(String),
}

/// Generates and checks the cases of a [`Scenario`]. Usually created with
/// `TestContext::<FuzzMode>::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<Spec>,
/// The mutations cases draw from, `None` for unmutated cases.
pub mutations: Option<Mutations>,
/// 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,
Expand All @@ -56,13 +70,18 @@ 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;

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;
Expand All @@ -71,14 +90,19 @@ 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);

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);
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions crates/test/src/fuzz/mod.rs
Original file line number Diff line number Diff line change
@@ -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<FuzzMode>) {
//! 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::<P2pkProgram>("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;
Expand Down
5 changes: 4 additions & 1 deletion crates/test/src/fuzz/mutations/core.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
9 changes: 9 additions & 0 deletions crates/test/src/fuzz/program.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -100,17 +102,21 @@ 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 {
self.raw_witness
.unwrap_or_else(|| panic!("`{}` spends no input, so it has no witness", self.name()))
}

/// The compiled program.
pub fn program(&self) -> &Program {
self.program
}
Expand All @@ -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()
}
Expand Down
14 changes: 10 additions & 4 deletions crates/test/src/fuzz/program_scenario.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ use crate::fuzz::witness_path::WitnessPath;
type Binding<T> = Box<dyn Fn(&T, &FuzzCase<'_>) -> T>;
type Transition = Box<dyn Fn(&FuzzCase<'_>) -> 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<fn() -> P>,
Expand Down Expand Up @@ -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<I>(self, name: &str, path: I) -> Self
where
I: IntoIterator,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions crates/test/src/fuzz/program_strategy/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]>,
Expand Down
11 changes: 11 additions & 0 deletions crates/test/src/fuzz/scenario.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<FuzzProgramScenario>,
Expand All @@ -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<P: FuzzProgramTrait>(&mut self, name: impl Into<String>) -> FuzzProgramScenarioBuilder<'_, P> {
self.program_scenarios.push(FuzzProgramScenario::new::<P>(name.into()));

Expand Down
7 changes: 7 additions & 0 deletions crates/test/src/fuzz/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<P: ProgramSchema, R: Rng + ?Sized>(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<P: ProgramSchema, R: Rng + ?Sized>(rng: &mut R) -> WitnessValues {
WitnessValues::from_map(random_fields(&P::witness_types(), rng))
}
Expand All @@ -27,6 +29,11 @@ pub(crate) fn random_fields<R: Rng + ?Sized>(
.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<R: Rng + ?Sized>(ty: &ResolvedType, rng: &mut R) -> Value {
match ty.as_inner() {
TypeInner::Either(left_ty, right_ty) => match rng.random::<bool>() {
Expand Down
Loading