Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Crucible

Deterministic simulation, UltraHonk proving, and conformance stress-testing for Stellar confidential tokens — built as three repositories that compile each other.

Crucible exists to make one claim checkable rather than assumed: what a Confidential Token transaction does locally is what it does on-chain. It does that by putting a deterministic execution model, a real proving backend, and a conformance harness around the same operations, then testing that they agree.

Verified on testnet, today

An UltraHonk verifier contract is live on Stellar testnet and this project verifies proofs against it:

ContractCCS6Z3VVCKV4F5BCH7VXJLKKWMDROUWOTZYROJ4T26CM7R45SE4IFYI2
Networktestnet (Test SDF Network ; September 2015)
Verification keythe transfer circuit’s, fixed at deploy
Deploy transaction2f821d07…ea7e52 — ledger 4569701
Proof verified by it5ef50bff…ad653e79 — ledger 4569705

Reproduce it from a checkout, with no key and no fee:

CRUCIBLE_SOROBAN_LIVE=1 cargo test -p crucible-soroban-adapter --test live

That suite submits a committed proof to the deployed contract through Soroban RPC simulation and asserts the pristine proof is accepted while a tampered one is rejected. Simulation is a real execution of the contract; it simply does not settle on-chain.

The contract is Nethermind’s audited rs-soroban-ultrahonk wrapper, not code from this project. Reimplementing UltraHonk verification on-chain would replace audited cryptography with unaudited code, so the project owns the integration instead. See the deployment record.

The three repositories

RepositoryRoleConsumes
crucible-simulatorSIMULATE — deterministic state and execution modelnothing
crucible-proverPROVE — circuits, witnesses, proving, verificationcrucible-simulator
crucible-scenariosSTRESS-TEST — orchestration, conformance, adversarial, privacy, regression, reportingcrucible-simulator, crucible-prover

The graph is acyclic and one-way. Every dependency edge is pinned to an immutable revision rather than a version range, and the pinned set is recorded and enforced by a test — see pinning.

Status, stated precisely

This project distinguishes what it has verified from what it has decided.

Verified

  • On-chain proof verification against a live testnet contract, including rejection of a tampered proof (the gated suite above).
  • The simulation layer’s determinism: state roots and fixture outcomes are pinned, and the hashing construction is pinned by known-answer vectors.
  • Artifact integrity: manifests, checksums, and loader rejection paths.

Not yet, and not claimed

  • The circuit scheme is scaffold-shaped until aligned with the Confidential Token circuit specification. Artifacts, keys, and proofs produced before that alignment must not be treated as final.
  • Stellar labels Confidential Tokens a developer preview; the contracts and verifier are under audit and not intended for production use.
  • Only the transfer circuit has on-chain fixtures. Register, deposit, merge and withdraw follow the same recipe when needed.
  • The local and on-chain toolchains emit different proof layouts (bb 6.0.0-nightly locally, bb 0.87.0 on the contract pin), so the two formats are each tested on their own terms rather than assumed interchangeable.
  • No independent cryptographic audit has been performed.

Where to start

About this site

Every page under Simulator, Prover, and Stress-test is generated at build time from the three source repositories — this site is a view, never a second copy. Each build records the exact revision it rendered, listed under Built from, so any page can be traced back to the commit that produced it.

▶ The Crucible pitch

Four minutes and fifty-two seconds. Every frame is a live capture.

If the player above does not start, the video is also attached to the pitch-v1 release.

What it covers

BeatPoint
The problemConfidential tokens hide what you transfer while proving the transfer was valid — and those two halves are usually built and audited separately, with almost nobody testing whether they agree.
The ideaCrucible makes one claim checkable rather than assumed: what a confidential token transaction does locally is what it does on-chain.
ArchitectureThree layers that compile into each other, with every edge pinned to an immutable revision so a change cannot silently drift underneath another layer.
SimulateDeterminism as an enforced property, not a habit — same configuration and seed give the same state, events, commitments and outcomes, bit for bit.
ProveReal Noir circuits compiled to ACIR, real UltraHonk proofs, and a CI step that recompiles the circuits and diffs the committed artifacts so a forgotten re-pin cannot land.
TestnetAn UltraHonk verifier contract deployed to Stellar testnet, with proofs verified against it from a checkout that needs no key and no fee.
Stress-testConformance, adversarial, privacy, regression and performance suites wrapped around the same operations, judged by the real engine rather than a stand-in.
RigourThe documented error-code surface, the determinism contracts, and the limits this project states about itself.

Why the video exists

A README makes a reader assemble the argument for themselves, in whatever order they happen to read. The pitch makes the argument once, in order, and shows the running system rather than describing it — which is the fastest way to answer the only question that matters first: what is this, and does it actually work?

Nothing in it is a mock-up and nothing in it is a slide about a roadmap. The documentation deployment, the repositories, and the testnet contract are all public, and every claim in the video is reproducible from a checkout.

Regenerating it

The pipeline that produced the video — narration script, live capture, synthesis, composition, and a probe that asserts the captured pages still contain what the video claims — is committed in Crucible-TDA/.github/pitch.

🔥 Crucible Simulator

CI MSRV License Pitch video

SIMULATE — a deterministic environment for reproducing and testing Stellar Confidential Token flows.

📖 Documentation site for all three repositories: https://crucible-docs-flame.vercel.app

▶ Watch the pitch — 4:52

Crucible product pitch video

Click the thumbnail to watch the full product pitch. It covers the problem, the three-layer architecture, where the simulator sits beneath the prover and above the state transitions, and the limits this project states about itself.

Every frame is a live capture — the deployed documentation site, the public repositories, and the deployed Stellar testnet verifier contract. Nothing in it is a mock-up, and nothing in it is a slide about a roadmap.

Pitch preview

                    CRUCIBLE
                       |
        ┌──────────────┼──────────────┐
        ▼              ▼              ▼
    SIMULATOR        PROVER       SCENARIOS
        │              │              │
     Execute         Prove         Challenge
     State           Crypto        Behavior
     Flows           Validity      Resilience

crucible-simulator is the execution and state-simulation foundation of the Crucible ecosystem. It reproduces the lifecycle of Confidential Token operations — Register → Deposit → Merge → Confidential Transfer → Withdraw — locally, deterministically, and inspectably, before any real prover or integration environment is involved. It is one of the three source repositories, joined by a fourth that renders their documentation:

RepositoryLayerResponsibility
crucible-simulatorSIMULATEreproduce Confidential Token flows and state transitions
crucible-proverPROVEgenerate and verify mock and real cryptographic proofs
crucible-scenariosSTRESS-TESTexecute conformance, failure, and adversarial scenarios
crucible-docsREADthe rendered documentation for all three layers — a build of their markdown, not a source of it

Why does this exist?

Testing confidential flows against a full application stack is slow and unreproducible. Crucible Simulator lets a developer say:

Create environment → Create token → Create accounts → Register accounts
→ Deposit assets → Generate state → Execute confidential operation
→ Update state → Inspect result → Assert expected state

…and reproduce it bit-for-bit, every time.

The defining property is determinism:

same configuration + same initial state + same operation sequence + same seed
   → same resulting state + same events + same commitments + same outcomes

Repository layout

crates/
├── core/        # Pure domain model: accounts, tokens, commitments,
│                #   balances, operations, transactions, events, errors
├── state/       # Deterministic state engine: store, commitment lifecycle,
│                #   nullifiers, transitions, rollback, snapshots, Merkle
├── flows/       # Lifecycle flows + ProofProvider boundary + validation
├── simulator/   # High-level API: Configuration, Environment, Simulator
├── fixtures/    # Versioned fixtures, loader, scenario runner
├── cli/         # The `crucible` command-line interface (stateful)
└── adapters/    # soroban: local vs Soroban/Testnet execution boundary
                 # testnet: network configuration + transcript runner
examples/        # Runnable demonstrations, one directory per example
fixtures/        # The canonical JSON fixture corpus (accounts, tokens,
                 #   commitments, balances, transactions, successful +
                 #   failure scenarios; environments are embedded)
schemas/         # JSON Schema mirrors of the fixture contracts
tests/           # Defining-property suites: integration, determinism,
                 #   flows (fuzz), state (invariants), regression
docs/            # Architecture and design documentation
benches/         # Criterion benchmarks: transfer, state, commitments,
                 #   fixtures

Dependency direction is strictly one way:

core  →  state  →  flows  →  simulator  →  fixtures

crucible-prover will implement the ProofProvider trait (defined in flows); nothing here imports a proving system.

Command-line interface

A stateful CLI runs the same environment across commands (one operation per ledger), persisted under --dir (default .crucible/):

cargo run -p crucible-cli -- init --seed 42
cargo run -p crucible-cli -- account create alice
cargo run -p crucible-cli -- account create bob
cargo run -p crucible-cli -- token create CCT --decimals 7
cargo run -p crucible-cli -- register 1 1
cargo run -p crucible-cli -- simulate deposit 1 1 1000 --reference pub-1
cargo run -p crucible-cli -- simulate transfer 1 1 300 --recipient 2
cargo run -p crucible-cli -- state inspect --json
cargo run -p crucible-cli -- snapshot create baseline
cargo run -p crucible-cli -- snapshot restore baseline
cargo run -p crucible-cli -- fixture run full-lifecycle

Examples and benchmarks

# Runnable demonstrations (register -> deposit -> transfer -> withdraw ...)
cargo run -p crucible --example basic-flow
cargo run -p crucible --example deposit-and-transfer
cargo run -p crucible --example transfer-and-withdraw
cargo run -p crucible --example multi-account
cargo run -p crucible --example state-snapshot
cargo run -p crucible --example testnet

# Performance targets for the pieces scenarios touch
cargo bench -p crucible --bench transfer
cargo bench -p crucible --bench state
cargo bench -p crucible --bench commitments
cargo bench -p crucible --bench fixtures

Installation & quick start

Requires stable Rust (see rust-toolchain.toml).

cargo build --workspace
cargo test  --workspace
cargo clippy --workspace --all-targets

The fastest way to see the whole lifecycle run is the integration suite:

cargo test -p crucible --test integration

The other defining-property suites live under tests/ on the root package (a deterministic tests/determinism, the invariant suite tests/state, regression fixtures tests/regression, and the seeded stress harness tests/flows). Per-crate unit tests stay in their crates — Cargo can only host one harness per test directory, so tests/unit maps to in-crate #[cfg(test)] modules by necessity.

A minimal simulation in code:

#![allow(unused)]
fn main() {
use crucible_simulator::{Simulator, Configuration};
use crucible_core::{AccountId, TokenId, types::SyntheticIdentity};

let mut sim = Simulator::local(42); // deterministic seed 42

sim.create_account(SyntheticIdentity::Alice)?;
sim.create_account(SyntheticIdentity::Bob)?;
sim.create_account(SyntheticIdentity::Issuer)?;
sim.create_confidential_token("CCT", 7, AccountId::new(4))?;

sim.register(AccountId::new(1), TokenId::new(1))?;
sim.register(AccountId::new(2), TokenId::new(1))?;
sim.deposit(AccountId::new(1), TokenId::new(1), 1000, "pub-ref")?;
sim.transfer(AccountId::new(1), AccountId::new(2), TokenId::new(1), 300)?;
sim.withdraw(AccountId::new(2), TokenId::new(1), 100, "pub-ref")?;

// Assert (private testing view; never emit into production output):
assert_eq!(sim.inspect_private_balance(AccountId::new(1), TokenId::new(1)), 700);
}

Replaying from a versioned fixture:

#![allow(unused)]
fn main() {
use crucible_fixtures::{load_fixture, run_scenario};
let loaded = load_fixture("fixtures/successful/full-lifecycle.json")?;
if let LoadedFixture::Scenario(scenario) = loaded {
    let outcome = run_scenario(&scenario)?;   // deterministic outcome
}
}

Core concepts

ConceptWhereNotes
AccountcoreSynthetic identities (Alice/Bob/…), registration + permission state
TokencoreNative test asset and configured confidential tokens
Commitmentcore/stateDeterministic digest over (owner, token, value, nonce); Active/Consumed lifecycle
BalancecoreObservable anchor (commitment) vs private simulation value
OperationcoreThe five lifecycle operations as one enum
TransactioncoreDeterministic-ID record of every operation — Success/Rejected/Failed with the error embedded
EventcoreValue-free observable output (success + operation_rejected)
StateStorestateBTree-backed deterministic store with a state-root digest
NullifierstateConsumed-commitment registry; replay protection
TransitionstateExplicit State0 → Transition → State1, audited on record
SnapshotstateNamed full-state captures with exact restore
ProofProviderflowsThe interface crucible-prover will implement; mock today
SimulatorsimulatorThe high-level deterministic API

Confidential flows

Every flow validates structure → authorization → state → proof, applies its changes inside a TransactionScope (failed flows roll back completely), and records a transaction, a value-free event, and an audited transition. Rejected and failed operations are recorded too: they produce a Rejected /Failed transaction and an operation_rejected event without ever mutating state — so conformance suites can assert on the rejection record.

  • Register — the gate; per-account, per-token; duplicates rejected.
  • Deposit — public asset → fresh commitment with a deterministic blinding nonce.
  • Merge — consolidate several owned commitments into one (sum).
  • Confidential transfer — spend the sender’s commitments (each is nullified), prove through the ProofProvider, create recipient + change commitments. The simulator never proves itself.
  • Withdraw — spend commitments back to the public world; change stays confidential.

Determinism & snapshots

Everything is a pure function of configuration + state + operation sequence

  • seed. The suite in tests/determinism compares full transcripts across runs. Snapshots let you capture state, run operations, restore, and rerun the exact sequence — the debugging workflow crucible-scenarios relies on.

The privacy boundary

The simulator is a testing environment, so it can expose internal state — but only through explicitly labeled private inspection APIs (inspect_private_balance, inspect_private_head, and the state store itself). Observable output (events, transaction records, proofs, fixtures for publication) never contains amounts or commitment values. This distinction mirrors Stellar’s Confidential Token design: addresses are public; balances and amounts are private.

Documentation

The documentation in this repository is rendered together with crucible-prover and crucible-scenarios at https://crucible-docs-flame.vercel.app. The markdown files here are the source; the site is a build of them. Rebuilds are nightly and on demand — a push here does not itself trigger one — so a change appears on the site within a day, or immediately if the documentation-site workflow is dispatched. To change a published page, change the file that owns it in this repository.

See docs/ for the architecture, the simulator model, the environment, the state model, commitments, deterministic execution, confidential flows, fixtures, snapshots, the Soroban adapter, the testnet configuration/runner, the security model and threat model, and how this repository integrates with crucible-prover and crucible-scenarios.

docs/error-codes.md is worth calling out separately: the stable machine-readable code attached to every failure is a public interface, because consumers branch on the string rather than the message. That document lists every code, what raises it, and whether retrying the same operation could help — and scripts/check-error-codes.py runs in CI so the table cannot drift away from the code it describes. See SECURITY.md for how to report issues, and CONTRIBUTING.md to contribute.

License

Licensed under either of

at your option, matching crucible-prover and crucible-scenarios.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this repository by you, as defined in the Apache License, shall be dual licensed as above, without any additional terms or conditions.

Architecture

The three Crucible repositories

                         CRUCIBLE
                            |
              ┌─────────────┼─────────────┐
              │             │             │
              ▼             ▼             ▼
       crucible-       crucible-     crucible-
       simulator        prover       scenarios
              │             │             │
              │             │             │
              │     ProofProvider         │
              │◄────────────┘             │
              │                           │
              ▼                           │
         Flow Engine                      │
              │                           │
              ▼                           │
         State Engine                     │
              │                           │
              ▼                           │
        Deterministic API ◄───────────────┘
RepositoryPropertyResponsibility
crucible-simulatorSimulateReproduce Confidential Token flows and state transitions
crucible-proverProveGenerate and verify mock/real cryptographic proofs
crucible-scenariosStress-TestExecute conformance, failure, and adversarial scenarios

crucible-simulator is the execution foundation the other two build on. It defines the interfaces (ProofProvider, the deterministic API, the fixture contracts); it never imports a prover.

Inside crucible-simulator

                 crucible-simulator
                         │
        ┌────────────────┼────────────────┐
        ▼                ▼                ▼
      CORE             FLOWS            STATE
        │                │                │
        │          ┌─────┼─────┐          │
        │          │     │     │          │
        │          ▼     ▼     ▼          │
        │       Deposit Merge Transfer    │
        │          │     │     │          │
        │          └─────┼─────┘          │
        │                │                │
        └────────────────┼────────────────┘
                         ▼
                    TRANSACTION
                         │
                         ▼
                       EVENT

crucible-core

Pure data model with no dependencies on the engines. Owns accounts, tokens, commitments, balances, operations, transactions, events, the error taxonomy, plus the deterministic foundation primitives (typed IDs, LedgerContext, DeterministicRng, domain-separated hashing).

crucible-state

The deterministic state engine. StateStore keeps every piece of state in sorted BTree collections and computes a canonical state-root digest. Every mutation is explicit: flows record StateTransitions audited against the store fingerprint, spend lifecycle is enforced through commitment status + nullifiers, and checkpoints/TransactionScope/snapshots make every change reversible.

crucible-flows

The lifecycle engine. The validation pipeline (structure → authorization → state → proof) runs before anything touches state; apply happens inside a rollback scope; record_success writes the transaction, event, and transition. The ProofProvider trait is the only place proofs enter.

crucible-simulator

The developer-facing API: Configuration (serializable, reproducible), Environment (config + store + RNG + ledger), Simulator (setup, flows, snapshots, inspection). All flow calls go through Simulator::execute, which dispatches on the operation and supplies a deterministic mock proof provider for confidential transfers.

crucible-fixtures

Versioned, machine-readable fixtures (accounts, tokens, environments, scenarios) plus the loader and the scenario runner. The canonical corpus lives at the repository root under fixtures/, mirrored by JSON Schemas in schemas/.

Dependency rules

  • One dependency direction: core → state → flows → simulator → fixtures.
  • core never references the other crates.
  • No crate imports a proving system. ProofProvider is implemented by crucible-prover and mocked here.
  • Fixture data contracts are stable; crucible-scenarios consumes them without importing simulator internals.

What deliberately does NOT belong here

No production wallet, frontend, compliance/sanctions/KYC engine, production prover or full Noir stack, scenario catalog (that is crucible-scenarios), blockchain explorer, or general-purpose Soroban test framework. The repository is specifically a deterministic Confidential Token flow simulator and state environment.

Commitments

A commitment is the atomic unit of confidential state:

Commitment
  ├── commitment_id   deterministic digest over (owner, token, value, nonce)
  ├── owner           the account that holds it
  ├── token           the asset it is denominated in
  ├── value           **private simulation state** (never observable output)
  ├── nonce           deterministic blinding nonce
  └── created_at      ledger sequence of creation

Commitment IDs are a pure function of their content — no randomness beyond the seeded nonce — which is exactly what makes “same seed + same operation sequence ⇒ same commitments” hold across runs and machines.

Lifecycle

create (Active) ──consume──▶ consumed + nullifier registered
  • Deposit creates an active commitment for the depositor.
  • Transfer consumes the sender’s active commitments (each registers a nullifier) and creates fresh commitments for the recipient and change.
  • Merge consumes several owned commitments and creates one carrying their summed value.
  • Withdraw consumes commitments and moves value back to the public world (with a change commitment when the withdrawal is partial).

Every mutation goes through the store’s commitment lifecycle, which guards the invariants:

  • Duplicate nonces are rejected — an identical live commitment cannot be created twice.
  • Consumed commitments are rejected — replaying a spent commitment fails with consumed_commitment (double-spend protection).
  • Wrong owners are rejected — an account can only consume its own commitments.

Nullifiers

When a commitment is consumed, its nullifier — a deterministic digest of the commitment ID — is registered in the store’s append-only registry. The lifecycle is strictly unused → consumed and never consumed → reused: any attempt to spend the same commitment again would re-derive the same nullifier and be rejected. The registry is a first-class part of the state root, so replaying a store reproduces it exactly, and the fuzz suite asserts the registry always matches the consumed set.

The privacy boundary

The commitment’s value is private simulation state. It lives in the state store and is visible to the test harness — that is the point of a simulator — but events, transactions, proofs, and published output never carry it. Observable records reference commitments by ID only (inputs, outputs on a transaction), never by value.

Merkle state

For flows that depend on accumulator-style structures, the state crate provides a sparse Merkle tree over commitment-style leaves with deterministic roots and membership paths (see docs/state-model.md and the crucible_state::merkle module) — the shape crucible-prover will consume for membership proofs.

Confidential flows

The lifecycle:

Register → Deposit → Merge → ConfidentialTransfer → Withdraw

The validation pipeline

Every operation passes four ordered checks before state changes:

Operation
   → structural validation     (well-formed, amounts positive, ...)
   → authorization validation  (signature valid, signer == actor, permission)
   → state validation          (accounts/tokens exist, registered, active, owned)
   → proof requirement         (present + valid where required)
   → apply transition

Authorization (“is the actor allowed to do this?”) is deliberately separate from cryptographic validity (“is the proof/signature well-formed?”). An operation can be cryptographically valid yet unauthorized — the exact case safeguard-hooks and crucible-scenarios will exercise later.

Operation recording

Every operation produces exactly one structured transaction, whatever its outcome:

Success   -> applied and committed (version bumped, transition recorded)
Rejected  -> failed validation/authorization/state/balance/proof before any
             mutation (nothing changed, operation_rejected event)
Failed    -> started inside the transaction scope and rolled back
             (nothing changed, operation_rejected event)

Rejected and failed operations never bump the version and never move the state root — atomicity is absolute — but they leave a deterministic-ID record with the structured error embedded, so conformance suites can assert on the rejection record, not only on the returned error. The operation_rejected event carries the stable error code in its metadata (never amounts).

Flow semantics

Register

Per (account, token). Duplicates are duplicate_registration. On tokens that require registration, this is the gate for everything else.

Deposit

Public asset → confidential commitment. A fresh blinding nonce is drawn from the seeded RNG, so equal-value deposits stay distinguishable but reproducible. The account’s confidential head points at the new commitment.

Merge

Consolidate several active commitments owned by the merger into one whose value is their sum (overflow-checked). Each input is consumed (nullified). Merging is how an account consolidates before a large withdrawal.

Confidential transfer

The most important flow:

Sender → select all active commitments → validate coverage
      → prove through ProofProvider
      → consume inputs (nullify each)
      → create recipient commitment (amount) + change commitment (if any)
      → update heads → record

The simulator never proves itself: it builds a ProofRequest with public inputs (sender, recipient, token, amount, input IDs) separate from private inputs (values), hands it to the configured provider, and fails with InvalidProof if no provider is present or the provider rejects. The ProofReference recorded on the transaction is opaque to the simulator.

Withdraw

Confidential → public. Spends the account’s active commitments; when withdrawing less than the total, a change commitment keeps the remainder confidential. Requires no proof — this is the public side of the boundary.

Events (observable output)

OperationSuccess eventRejection event
Registeraccount_registeredoperation_rejected
Depositdeposit_completedoperation_rejected
Mergemerge_completedoperation_rejected
Transferconfidential_transfer_completedoperation_rejected
Withdrawwithdrawal_completedoperation_rejected

Events never carry amounts or commitment values. They report the actor, token, ledger, and a stable result — enough to describe what happened without leaking how much. A rejection event adds only the stable error code to its metadata. The privacy boundary is enforced by design and asserted by regression tests.

Contributing to Crucible Simulator

See CONTRIBUTING.md for the process (issues, branches, PRs, review expectations). This page is the map for what there is to contribute, aligned with the repository’s structure — the same surface the issue templates describe.

Where things live

AreaPathWhat lives there
Domain modelcrates/coreaccounts, tokens, commitments, balances, operations, transactions, events, errors
State enginecrates/statestore, commitment lifecycle, nullifiers, transitions, rollback, snapshots, Merkle
Flow enginecrates/flowsthe five flows, authorization, validation, ProofProvider boundary
High-level APIcrates/simulatorConfiguration, Environment, Simulator (examples and benches live on the root package)
Fixturescrates/fixtures + fixtures/ + schemas/typed fixtures, loader, scenario runner, the corpus, the schemas
CLIclithe stateful crucible binary
Boundariescrates/adapters/*Soroban adapter, testnet configuration + runner
Root packageexamples/, tests/, benches/runnable examples, defining-property suites, criterion benchmarks

Rules of the house

  1. Determinism is sacred. Never introduce system entropy, unordered collections into anything serialized or hashed, or platform-dependent behavior. If your change alters the state-root scheme, commitment IDs, or event/transaction output, existing deterministic anchors (Merkle root regression tests, fixture outcomes) must be regenerated deliberately and called out in the PR.
  2. Privacy boundary. Private values (amounts, commitment values, balances) never enter events, proofs, or published output. Tests may assert on inspect_private_*; output may not leak it.
  3. Atomicity. A failed operation must leave no partial state. Failed operations are still recorded (as Rejected/Failed transactions with an operation_rejected event) — do not regress that to silent errors.
  4. One operation, one ledger. Flows advance at most one ledger step per executed operation.
  5. Do not raise sha2 past 0.10 on its own. This crate’s digests are a published interface: crucible-prover pins a simulator revision and compiles it, and it also depends on stellar-xdr 28, which requires sha2 0.10. Raising sha2 here resolves two SHA-256 implementations into that consumer’s binary and is not a change one repository can make. The analysis, the reproduction, and the acceptance test for a future bump are in crucible-prover/docs/dependency-policy.md. Any change to the hashed encoding itself must be paired with regenerated known-answer vectors in crates/core/src/hashing.rs, which pin the digests by value.

Great first contributions

  • A new failure fixture — every bug you find becomes fixtures/failures/*.json with an expected_error, and the corpus-replay test keeps it green forever.
  • A determinism or invariant test — the suites under tests/ on the root package (tests/determinism, tests/state, tests/regression, tests/flows) are where defining properties are pinned.
  • Flow edge cases — the issue template names the success/failure surface; flow unit tests in crates/flows/src/<flow>.rs are the pattern to follow.
  • Docs — every doc under docs/ matches a spec section; stale docs are bugs.

Local development loop

cargo fmt --all -- --check   # formatting
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace      # everything, including the fuzz suite
cargo bench -p crucible            # objective performance targets

Deterministic execution

Determinism is the defining property of Crucible:

same configuration
+ same initial state
+ same operation sequence
+ same seed
= same resulting state + same events + same commitments + same outcomes

Where nondeterminism would hide — and how the code prevents it

Source of nondeterminismPrevention
System entropy (randomness)All randomness flows through DeterministicRng (SplitMix64), a pure function of a u64 seed. Commitments, proofs, and identities never touch the system RNG.
Wall-clock timeTime is a synthetic LedgerContext advanced by a fixed configured step (protocol.step_seconds). Nothing reads the clock.
Iteration orderEvery state collection is a BTree* map/set, so iteration is sorted. Hash* collections are never used for state.
Hash randomizationHashing is domain-separated SHA-256 over canonical byte/JSON inputs, never DefaultHasher.
Serialization driftTransaction IDs digest canonically serialized payloads (serde_json), so they do not depend on Rust formatting details.
PRNG draw orderFlows draw nonces in fixed code order, so the same operation sequence consumes the stream identically.
Floating pointAll value math is integer (u128); wrapping overflow is explicit (wrapping_* or checked_*).

The reproducible-input contract

A scenario is fully described by:

fixture (environment + seed + ordered steps)

The fixture is versioned JSON (kind, version, environment.seed, steps); the loader version-checks it and the runner replays it against a fresh Simulator. Identical fixtures produce identical ScenarioOutcomes (asserted in the determinism suite, including the failure code of expected-failure scenarios).

What is compared

The determinism suite compares, across independent runs:

  • the state root after every operation,
  • transaction IDs,
  • event IDs,
  • output (commitment) IDs,
  • full serialized stores (private values included),
  • operation results, and
  • error codes for failed operations.

Different seeds diverge only in private state (blinding nonces change the commitments) — structural facts (counts, versions, error codes) agree.

Ledger context

LedgerContext { seq, timestamp } starts from ledger.initial_seq / ledger.start_timestamp and advances deterministically. Simulator::advance_ledger() is an explicit step for scenarios that want multi-ledger behavior; it never runs on a timer.

The simulation environment

An Environment bundles everything a simulation runs against (spec section 4):

Environment
  +-- network/protocol/token/ledger/randomness configuration
  +-- the deterministic state store
  +-- the seeded deterministic RNG
  +-- the current ledger context

Constructing an environment from a Configuration is a pure operation: the store starts empty, the RNG is seeded from the configuration, and the ledger starts at the configured sequence and timestamp. Two environments built from the same configuration are indistinguishable.

Configuration

Configuration is fully serializable and holds:

  • network — local (default) or testnet target;
  • protocol — step seconds the synthetic ledger advances by;
  • ledger — initial sequence and start timestamp;
  • token — default decimals, transferability policy;
  • account — default registration requirements;
  • randomness — the seed that drives every nonce.

The deterministic story starts here: the seed is the only source of randomness in the system. The environment’s RNG is SplitMix64 seeded from it, and everything random (blinding nonces) is drawn from that stream — never from system entropy.

The environment is a complete, resumable state

Because the environment serializes in full — configuration, store (accounts, commitments, statuses, nullifiers, logs), RNG stream position, and ledger — persisting an environment and reloading it resumes the exact same simulation. The CLI uses this to keep a stateful working directory: every command loads, runs one operation per ledger, and saves back.

Snapshots include the random stream

Environment::snapshot(name) records the RNG position next to the state payload; restore_snapshot(name) rewinds both. Restoring a mid-stream snapshot and rerunning the same operations therefore draws the same nonces and reproduces the identical commitments and final state (spec section 23). See docs/snapshots.md.

Modes

  • Local — the default environment: offline, fast, deterministic.
  • Testnet — described by TestnetConfiguration; execution behind the Soroban adapter boundary (see docs/testnet.md). Local simulation never depends on it.

Error codes

Every validation and execution failure carries a stable machine-readable code in addition to a human-readable message. Downstream consumers branch on the code, not on the message text: crucible-scenarios asserts that a transfer from an unregistered account fails with not_registered, and the regression suite pins the exact code a fixed bug used to produce.

Two properties this document exists to guarantee:

  • Every code the code can return is listed here. A consumer can look up any code it observes without reading this repository’s source.
  • Every code listed here exists. A stale table is worse than no table, because consumers trust it.

Both are enforced by scripts/check-error-codes.py, which extracts the codes returned by every code() method and compares them against the tables below. It runs in CI, so documentation drift is a build failure rather than something a reader discovers.

Why strings, not numbers

Codes are short snake_case strings rather than numeric constants such as E1042. A string is self-describing in a log line, a fixture, or a JUnit report, and it survives a reordering of the enum it comes from — with numbers, adding a variant in the middle of an enum silently renumbers every code after it. The trade is that codes are not compact; that has not mattered here.

The stability contract

A code is a public interface. Treat it as one:

  • Never rename a code. Renaming breaks every consumer’s match. If a code’s meaning has to change, add a new code and keep the old one until consumers migrate.
  • Never reuse a retired code for a different condition.
  • Adding a new code is not a breaking change; consumers should treat an unrecognised code as “some failure I do not specifically handle” rather than as a panic or an unreachable state.

Codes

Core validation

Raised while checking that an operation is well-formed, before any state is touched. A failure here means the request itself is wrong.

CodeCategoryRetryableRaised when
invalid_operationvalidationnoThe operation is malformed or structurally invalid.
invalid_accountvalidationnoA referenced account does not exist.
invalid_tokenvalidationnoA referenced token does not exist.
zero_amountvalidationnoAn amount is present but not strictly positive.

Authorization

The operation is well-formed and may be cryptographically valid, yet the actor is not entitled to perform it. Kept distinct from validation on purpose: the fix is a different actor, not a different request.

CodeCategoryRetryableRaised when
permission_deniedauthorizationnoAn account transacts on a token it has no permission for.
unauthorizedauthorizationnoThe actor is not authorized for this operation.
wrong_ownerauthorizationnoA commitment belongs to a different owner than the one claiming it.

Registration and accounts

CodeCategoryRetryableRaised when
not_registeredstatenoAn account is not registered for the token it is transacting on.
duplicate_registrationconflictnoAn account registers twice for the same token.

Value and commitments

The state preconditions that make a confidential transfer sound.

CodeCategoryRetryableRaised when
insufficient_balancestatenoAvailable confidential value is less than the requested amount. The message carries both figures.
unknown_commitmentstatenoA referenced commitment does not exist.
inactive_commitmentstatenoA commitment is referenced but is not in an active state.
consumed_commitmentstatenoA commitment was already consumed — a double-spend attempt. Also the code a replayed deposit produces.
invalid_proofcryptographicnoA proof reference is missing, unverifiable, or invalid.
invalid_statestatenoThe operation would move the system into a state that violates its invariants.

Internal

CodeCategoryRetryableRaised when
state_corruptioninternalnoA recorded transition disagrees with the state it describes, or another internal invariant is violated. This should never happen; it indicates a bug in the simulator rather than a bad request.

Snapshots

CodeCategoryRetryableRaised when
unknown_snapshotsnapshotnoA named snapshot does not exist.
duplicate_snapshotsnapshotnoA named snapshot already exists.

Fixtures

CodeCategoryRetryableRaised when
invalid_fixturefixturenoA fixture failed to parse or failed its version check.

Soroban adapter

Raised at the execution boundary when an operation is translated into a contract call. The adapter also passes through every core code it receives from the simulator rather than re-coding it, so a consumer sees one code for one condition regardless of where it was raised.

CodeCategoryRetryableRaised when
missing_amountadapternoThe adapter needs an amount but none was supplied.
bad_argumentsadapternoArguments did not match the call’s expected arity or types.
unimplementedadapternoThe operation has no contract-call translation.

Command-line interface

Raised by the crucible binary. The CLI also passes through the simulator’s codes, so crucible --dir /tmp/x transfer ... on an unregistered account reports not_registered, not a CLI-specific wrapper.

CodeCategoryRetryableRaised when
ioenvironmentyesReading or writing the persisted environment or a fixture failed. The only code in this table worth retrying: the cause is the filesystem, not the request.
corrupt_environmentenvironmentnoThe persisted environment exists but is unreadable or does not parse.
usageusagenoThe command line was wrong: an unknown identity, an unknown fixture, a missing argument.

Using codes

#![allow(unused)]
fn main() {
use crucible_core::errors::Error;

match error {
    // Branch on the code when what matters is the condition, not the payload.
    e if e.code() == "consumed_commitment" => report_double_spend(),
    e if e.code() == "insufficient_balance" => report_underfunded(),
    other => return Err(other.into()),
}
}

The typed variants remain the better choice inside Rust, because a match on the enum is exhaustive and the compiler checks it. Codes exist for the places a type cannot go: a fixture file, a JSON report, a CLI exit path, or another language.

Fixtures

Fixtures are the reproducibility contract of the ecosystem:

Fixture + Environment + Seed + Operation sequence = Reproducible result

Every fixture is versioned machine-readable JSON ("kind", "version": 1) whose shape is mirrored by JSON Schema files under schemas/. The loader rejects unknown kinds and unsupported versions.

Fixture kinds

  • account — a synthetic identity account (identity + name + account_id).
  • token — asset code, ID, issuer, decimals, confidential/registration/ transferable policy.
  • environment — a named (seed) configuration a scenario runs under.
  • scenario — an environment plus an ordered list of steps: create_account, create_named_account, create_native_asset, create_confidential_token, register, deposit, merge, transfer, withdraw, snapshot, restore, advance_ledger.

Failure scenarios declare "expected_error": "<code>" and are expected to end in failure with exactly that code; any other error — or success where failure was expected — makes the replay itself fail. This is also the shape of future regression fixtures: bug → fixture → test → permanent protection.

The canonical corpus

fixtures/
├── accounts/        alice, bob, carol, issuer, auditor
├── tokens/          confidential-token (CCT), native-test-asset (TEST)
├── balances/        confidential-balance state records
├── commitments/     commitment records
├── transactions/    transaction-record fixtures
├── successful/      deposit, transfer, withdrawal, full-lifecycle
└── failures/        insufficient-balance, unregistered-recipient,
                     double-registration

Environments are not separate files: every scenario fixture embeds the environment it runs under, so a scenario file alone is a complete repro.

A loader test replays the entire corpus on every run, keeping every checked-in fixture loadable and runnable.

Replaying

#![allow(unused)]
fn main() {
let loaded = load_fixture("fixtures/successful/full-lifecycle.json")?;
if let LoadedFixture::Scenario(scenario) = loaded {
    let outcome = run_scenario(&scenario)?; // ScenarioOutcome
}
}

ScenarioOutcome carries the resulting state fingerprint, step / transaction / event counts, and — for failure fixtures — the error code. Equal seeds produce equal outcomes (asserted); that is reproducibility in practice. When a contributor reports “transfer scenario fails”, the fixture file is the repro.

Integration with the Crucible polyrepos

crucible-simulator is one of three repositories:

RepositoryPropertyResponsibility
crucible-simulatorSimulateReproduce Confidential Token flows and state transitions
crucible-proverProveGenerate and verify mock/real cryptographic proofs
crucible-scenariosStress-TestExecute conformance, failure, and adversarial scenarios

The boundaries are interfaces, not imports. This repository defines what it needs and never reaches into the others.

With crucible-prover — the ProofProvider boundary

             crucible-prover
                    |
                    | implements ProofProvider
                    v
             crucible-simulator
                    |
                    v
              Flow engine -> State engine
  • The interface lives here: ProofProvider (in crates/flows).
  • crucible-prover implements it — mock, Noir, or UltraHonk, the simulator does not care.
  • Today the simulator ships MockProofProvider, a deterministic stand-in so flows can run end to end. Validation still requires a proof reference for confidential transfers, so swapping in a real provider cannot silently weaken the pipeline.

A scenario says “generate proof”, the simulator says “prove this” through the provider, and the flow proceeds only on a valid reference.

With crucible-scenarios — the stable API

crucible-simulator          crucible-scenarios
        |                          |
        |  stable API (crates)     | scenario logic
        +------------------------->+

crucible-scenarios consumes the simulator through the stable public API and the published fixture corpus — it does not import internal modules. A scenario is expressed exactly the way the spec describes:

Create environment
Register Alice, Register Bob
Deposit to Alice
Generate proof (via the provider boundary)
Transfer Alice -> Bob
Assert result, assert state, assert events

Three things make that possible:

  1. Deterministic outcomes — a scenario is fixture + environment + seed + operation sequence and its result is reproducible, so failures are communicated as (fixture, environment, seed, sequence).
  2. Explicit transitions — every operation records State0 -> Transition -> State1, so a scenario can assert on intermediate and final state.
  3. The privacy boundary — scenarios assert on private state through the labeled inspection APIs and on observable behavior through events/transactions, matching the distinction real Confidential Token semantics require.

Compatibility guarantees

The simulator API carries explicit stability expectations because crucible-scenarios builds on it: semantic versioning, an audited transition log, and a deterministic state-root scheme whose changes are tracked (see docs/deterministic-execution.md).

Security model

Crucible Simulator is a testing environment, not a production system. It models Stellar Confidential Token flows so their behavior can be exercised deterministically. Its security posture follows from that role, and the boundaries are documented so nobody mistakes the simulation for the real thing.

What this repository is and is not

It is a deterministic simulation of Confidential Token flows and state transitions
It is not a wallet, a compliance dashboard, a KYC system, or a sanctions engine
It is not a proving system — it accepts proofs through the ProofProvider boundary
It does not invent cryptographic primitives; it models the structures flows need

The privacy boundary

The single most important boundary in the codebase is between private simulation state and publicly observable output.

  • Private simulation state — commitment values, account balances, confidential heads — is reachable only through explicitly labeled testing APIs (inspect_private_balance, inspect_private_head) and the state store itself. It is internal.
  • Observable output — events, transaction records, proofs, published fixtures — is value-free by construction. Event carries no amounts; Transaction records inputs/outputs by commitment ID, not value.

The rule for contributors: never write a value or a commitment value into an event, a proof, or any serialized output intended for publication. Tests may assert on private state; output may not leak it.

Trust assumptions in the simulation

Because this is a simulator, several things that production must resist are assumed safe and are the responsibility of the real implementation:

  1. Synthetic identities are trusted. Authorizations are synthesized for the acting account; there is no key management. Unauthorized operations are still rejected (authorization validation exists and is tested), but the identity layer itself is simulated.
  2. The mock proof provider always proves. MockProofProvider attests validity deterministically. The validation pipeline still requires a proof reference for confidential transfers, so a missing or invalid proof is rejected — but a real provider must do the actual cryptographic work.
  3. Hashes are not adversarial-strength secrets. SHA-256 with domain separation gives deterministic, collision-resistant digests for testing purposes. Nothing here is a commitment scheme safe against a determined attacker; production uses the real Confidential Token primitives.
  4. Internal values are visible to the harness by design. That is the point of a simulator. It must never be confused with production confidentiality.

Guarantees the simulator does provide

Within its role, the simulator is strict:

  • Determinism — same configuration + state + operations + seed ⇒ same result. No system entropy, no hash-map iteration order, no platform dependencies (see docs/deterministic-execution.md).
  • Atomicity — a failed operation rolls back completely; partial state is impossible (see docs/state-model.md).
  • Replay protection — consumed commitments are nullified and can never be spent again; double-spend replays fail with consumed_commitment.
  • Auditability — every state change is an explicit, recorded transition between roots; corruption is detectable by replaying.

Reporting vulnerabilities

See SECURITY.md for the process. Because this repository never touches real assets, the main reportable classes are correctness bugs (state corruption, nondeterminism, rollback failures) and leaks of private simulation state into observable output.

The simulator model

What Crucible Simulator is, in one picture:

Simulator
  ├── Environment        configuration + state store + RNG + ledger
  ├── Accounts           synthetic identities (Alice, Bob, Carol, Issuer, Auditor)
  ├── Tokens             native test asset and configured confidential tokens
  ├── State              commitments, nullifiers, transitions, snapshots
  ├── Flows              register -> deposit -> merge -> transfer -> withdraw
  ├── Transactions       every operation produces one (Success/Rejected/Failed)
  └── Events             value-free observable output

A developer drives it the way the spec’s MVP does:

Create environment → Create token → Create accounts → Register accounts
→ Deposit assets → Generate state → Execute confidential operation
→ Update state → Inspect result → Assert expected state

The two promises that make the model usable:

  1. Determinism — the same configuration, initial state, operation sequence, and seed produce the same resulting state, events, commitments, and transaction outcomes, bit for bit, on any machine.
  2. Inspectability — every operation leaves an auditable trail (transaction record, event, explicit State0 → Transition → State1) and the private simulation state behind the balances is reachable through the labeled inspect_private_* testing APIs.

What the simulator is NOT

It is a testing environment, not a production system:

  • it does not prove anything — proofs enter through the ProofProvider boundary (see docs/integration.md);
  • it does not hold real identities or assets — accounts are synthetic and deterministic;
  • it does not invent cryptography — commitments and Merkle structures model the shapes flows need, with domain-separated SHA-256 for determinism rather than adversarial strength (see docs/security.md).

Operation outcomes

Since every operation is recorded, a scenario can assert on all three outcome classes:

StatusMeaning
SuccessApplied and committed: version bumped, transition recorded
RejectedFailed validation/authorization/state/proof before any mutation; nothing changed, operation_rejected event emitted
FailedStarted inside the transaction scope and rolled back; nothing changed, operation_rejected event emitted

Rejected and failed operations never bump the version and never move the state root — atomicity is absolute — but they leave a structured, deterministic-ID transaction and event behind, so conformance suites can assert on the rejection record, not just on the error return.

Snapshots

Snapshots let a simulation capture state, run arbitrary operations, and restore the exact prior state:

State A ──(deposit, transfer, merge)──▶ State B
  ▲                                       │
  └──────────────── restore ──────────────┘

Semantics

  • create_snapshot(name) captures the whole store — accounts, commitments, statuses, nullifiers, logs, sequence counters, version, and the snapshot registry as it stands — under a caller-chosen name. Duplicates are duplicate_snapshot.
  • restore_snapshot(name) replaces the store with the captured state and re-registers the snapshot, so the same snapshot can be restored repeatedly. Restoring is exact: no diff, no reconciliation.
  • delete_snapshot(name) removes a snapshot (unknown_snapshot if absent).

Why exactness matters

Restoring a baseline and re-running the same sequence reproduces the exact same results — including transaction IDs and commitments — because sequence counters are restored with the store. The random stream is part of the picture too: Environment::snapshot records the RNG position next to the store payload, and restore_snapshot rewinds the stream to that position, so a rerun draws the same blinding nonces and lands on the identical commitments, events, and state root. (This is why rerunning an operation sequence after a restore reproduces the exact final state; see docs/deterministic-execution.md.)

Snapshot/restore is the debugging and scenario-generation workflow: capture, experiment, restore, compare.

Cost model

Each snapshot clones the whole store: O(state) per snapshot and per rollback. This is a deliberate trade — exactness over incremental diffing — appropriate for a deterministic testing environment where states are small.

Soroban integration

The adapter crate (crates/adapters/soroban) is the boundary between local deterministic simulation and actual Stellar/Soroban execution:

Crucible Simulator
     ├── LocalAdapter   -> local deterministic environment (fast, default)
     └── TestnetAdapter -> Soroban RPC on Stellar Testnet (slow, optional)

The rule from the spec is absolute: the local simulator must never require Testnet access. It doesn’t — the default adapter executes operations through the simulator itself.

The adapter trait

Higher layers depend only on SorobanAdapter, not on any concrete execution environment:

#![allow(unused)]
fn main() {
pub trait SorobanAdapter {
    fn name(&self) -> &'static str;                     // "local" | "testnet"
    fn simulate(&mut self, request: &AdapterRequest)    // normalized invocation
        -> Result<AdapterResponse, AdapterError>;
}
}
  • AdapterRequest normalizes a lifecycle operation into a kind + numeric arguments + optional amount/reference, so callers (tooling, future contracts) do not depend on the simulator’s internal operation types.
  • AdapterResponse returns the outcome summary, event names, ledger sequence, and resulting state root — everything an outer layer needs.
  • AdapterError carries the structured simulator error code (e.g. insufficient_balance, consumed_commitment) through translation, so callers can branch on machine-readable codes rather than string content.

LocalAdapter — the default

LocalAdapter wraps a Simulator and maps normalized requests onto the high-level API. It is deterministic by construction and fully covered end to end: register → deposit → transfer → withdraw through the adapter produces the expected events, balances, and errors.

TestnetAdapter — the boundary, not the wiring

TestnetAdapter exists today so downstream code can be written against the interface now and swapped later. Every call returns AdapterError::Unimplemented — a structured error, not a silent success — until the Soroban SDK integration lands. Concretely, the roadmap work is:

  1. Build a real Soroban client (contract invocation → simulation → response parsing → event extraction) using the shapes in client.rs (ContractInvocation, ContractResult).
  2. Translate contract responses and RPC errors into AdapterResponse/AdapterError using the same vocabulary the local adapter uses, so scenario expectations do not change.
  3. Run the testnet suites separately from the deterministic suites (see docs/testnet.md).

Because both adapters implement the same trait over the same request shapes, switching execution environments later is a configuration change, not a code change.

State model

Explicit transitions

The state engine never silently mutates state:

State0 ──operation──▶ Transition ──apply──▶ State1

Every applied operation produces a StateTransition recording the operation, the concrete Changes, the state before, the state after, and the events emitted. Recording is audited: a transition whose resulting_state does not exactly match the store’s current fingerprint is rejected as StateCorruption, so bookkeeping mistakes fail loudly.

Transitions chain: consecutive records satisfy previous_state == preceding.resulting_state, and every transition advances the state version by exactly one.

What the store owns

CollectionType keyNotes
AccountsAccountIdRegistration + permission state, confidential heads
TokensTokenIdPolicy configuration
CommitmentsCommitmentIdValue + blinding nonce (private simulation state)
StatusesCommitmentIdActive / Consumed
NullifiersNullifierReplay protection; one per consumed commitment
TransactionsTransactionIdDeterministic records of applied operations
EventsEventIdValue-free observable output
TransitionsorderedThe audited transition log
SnapshotsnameFull-state captures

All collections are BTree* so iteration order — and therefore the state root — is deterministic.

State root

StateStore::state_ref() digests the version plus canonical JSON of every account, token, commitment (+ status), and nullifier in sorted order. The root is an internal simulation artifact (commitments carry private values into it); it is what determinism tests compare and what transactions refer to as state_before / state_after.

The commitment lifecycle

create (Active) ──consume──▶ Consumed + nullifier registered
  • create_commitment requires the owner and token to exist and rejects nonce collisions. Re-creating an already-consumed identical commitment is rejected as a double spend (ConsumedCommitment).
  • Consuming registers the commitment’s deterministic nullifier in the same step, so spending and replay protection cannot diverge.
  • Flows spend all of an account’s active commitments for a token at once; balances are the sum of active commitment values.

Snapshots and rollback

  • TransactionScope::begin captures a full-state checkpoint; dropping the scope without commit() restores it exactly — failed operations can never leave partial state, nullifier gaps, or skipped sequence numbers.
  • Named snapshots capture the whole store; restore_snapshot replaces the store with the captured state and re-registers the snapshot, so the same snapshot can be restored repeatedly. Cost is O(state) — the deliberate trade for a deterministic testing environment.

Invariants enforced (and tested)

  1. Conservation — transfers never create or destroy value.
  2. Commitment integrity — consumed commitments are never reused; each consumption registers exactly one nullifier; active + consumed == created.
  3. Registration — unregistered accounts cannot hold or move confidential value on registration-required tokens.
  4. Atomicity — failed operations leave the state fingerprint bit-identical.
  5. Determinism — same inputs produce the same state, commitments, events, and outcomes.

The invariant suite (tests/state) drives deterministic pseudo-random operation sequences to probe these properties.

Local mode vs Testnet mode

The simulator explicitly supports two modes (spec section 31). They serve different jobs and must never be confused:

Local simulation                     Testnet integration
--------------------------           --------------------------
Crucible Simulator                   Crucible Simulator
      |                                    |
      v                                    v
Local deterministic environment      Soroban adapter
      |                                    |
      v                                    v
Fast, reproducible, offline          Stellar Testnet (optional)

Local mode (default)

The whole repository — every crate, every test suite, the CLI, the examples, the benchmarks — runs against the local deterministic environment. It requires no network, no accounts, no SDK.

  • Deterministic: same configuration + state + operations + seed ⇒ the same result, bit for bit, on any machine.
  • Fast: confidential transfers run in tens of microseconds.
  • Inspectable: private simulation state is visible to the harness through the labeled inspect_private_* APIs.

This is where scenarios are designed, fixtures are built, and bugs are reproduced.

Testnet mode (optional, on the roadmap)

Testnet execution routes normalized operations through the Soroban adapter (crates/adapters/soroban) toward actual Stellar infrastructure. Today the TestnetAdapter is an explicit unimplemented boundary: it exists so callers and scenario code can target the interface, and it fails with a structured unimplemented error rather than pretending to work.

When the SDK wiring lands, testnet execution will be:

  • Slower and optional — never part of the deterministic suites.
  • Separately gated — run explicitly, outside cargo test, against a live network.
  • Cross-checked — the same AdapterRequest shapes drive both modes, so a scenario passing locally and failing on testnet pinpoints a real semantic divergence rather than an interface mismatch.

Guarantee

Local simulation never depends on Testnet access: the deterministic test suite, CLI, examples, and benchmarks run offline, in CI, on every push.

Threat model

Threats to a testing simulator are not the same as threats to a production ledger. Here they are attacks on the correctness and trustworthiness of the simulation itself: an attacker (or a bug, or a malformed fixture) tries to make the simulator produce wrong state, hide a violation, or diverge between runs. Every threat below maps to a defense that exists in the code and is exercised by the test suites.

State corruption

Threat. A sequence of operations leaves the store internally inconsistent — value created or destroyed, commitments referencing nothing, logs out of step with state.

Defense. All flows apply changes inside a TransactionScope; failures roll back completely (atomicity). The state root digests the canonical store, so any drift changes the root and is caught by replay. The seeded stress suite (tests/flows) runs random sequences and asserts conservation, nullifier integrity, and atomicity after every step.

Commitment reuse / double spend

Threat. A consumed commitment is spent again, creating value from nothing.

Defense. Consumption registers a deterministic nullifier derived from the commitment ID. Replaying the same commitment fails with consumed_commitment; assert_not_consumed gates every consume path. Dedicated tests and fuzz seeds probe this surface.

Nullifier forgery / registry drift

Threat. Nullifiers registered for commitments that were not consumed (locking funds) or missing for consumed ones (allowing double spend).

Defense. The registry is append-only and derived from consumption; the fuzz suite asserts the registry exactly matches the consumed set after every scenario.

Nondeterministic execution

Threat. The same inputs produce different outputs across runs, machines, or releases — destroying reproducibility.

Defense. No system entropy: every random draw comes from the seeded DeterministicRng. All collections are BTree*. Hashing is fixed SHA-256 with domain separation. The determinism suite replays transcripts and compares roots; a Merkle root regression anchor pins the digest scheme across releases.

Fixture poisoning

Threat. A malformed or malicious fixture (bad schema version, wrong types, impossible amounts) crashes the loader or produces corrupted state.

Defense. Fixtures are versioned JSON validated against their schemas; the loader rejects unknown schema versions and type errors with structured errors. Regression fixtures permanently pin every discovered bug.

Rollback failure

Threat. A mid-operation failure leaves partial state (some commitments consumed, others not).

Defense. TransactionScope wraps validate→apply→record; any error discards the scope and the store is untouched. The failure fixtures and the fuzz suite assert that failed operations never move the state root.

Incorrect proof-provider handling

Threat. The simulator accepts an operation whose proof is missing or invalid, or trusts a proof for the wrong operation.

Defense. The validation pipeline requires a present, valid proof reference for confidential transfers; validate_proof_requirement rejects missing and invalid references before any state change. The provider is behind the ProofProvider trait — the simulator never proves itself, and a broken provider cannot silently weaken validation.

State leakage

Threat. Private simulation values (amounts, balances, commitment values) appear in observable output such as events or published fixtures.

Defense. Event and observable records are value-free by construction; private values are reachable only through the explicitly labeled inspect_private_* APIs. See docs/security.md.

Adapter inconsistencies

Threat. Local simulation and Soroban/testnet execution disagree about operation semantics, so a scenario passes locally but fails on the real contract.

Defense. Both execution paths go through the same normalized AdapterRequest shapes and the same flow engine. The testnet adapter is an explicit, structured “unimplemented” boundary today — divergence is impossible until the SDK wiring exists, and the local adapter is fully covered end to end (see docs/soroban.md).

Testnet/local divergence

Threat. A scenario relies on a property that only exists in one mode.

Defense. Local simulation is the default and never requires network access. Testnet execution is optional, gated behind the adapter trait, and runs separately from the deterministic suites (see docs/testnet.md).

Malicious operation sequences

Threat. An adversarial ordering of otherwise-valid operations — e.g. depositing after withdrawal, merging consumed inputs, transferring to yourself to reset nonces — exploits an assumption in the state machine.

Defense. Structural, authorization, and state validation reject the classes above (self-transfer, zero amounts, unregistered recipients, duplicate registration, wrong-owner merges). The fuzz suite generates precisely such sequences — ghost accounts, wrong-owner signatures, zero amounts, empty merges — and asserts nothing corrupts.

Contributing to Crucible Simulator

Thanks for contributing! Crucible is a deterministic testing foundation, so its quality bar is exactness: every behavior change must come with tests that pin it down.

Getting started

cargo build --workspace
cargo test  --workspace
cargo clippy --workspace --all-targets   # must be warning-free
cargo fmt    --check

The workspace must stay clippy- and fmt-clean; CI enforces it.

Where things live

AreaCrateNotes
Domain modelcrates/coreNo engine dependencies
State enginecrates/stateStore, commitments, nullifiers, transitions, rollback, snapshots
Flowscrates/flowsFive lifecycle flows + validation + ProofProvider
High-level APIcrates/simulatorConfiguration / Environment / Simulator
Fixtures & suitescrates/fixturesVersioned fixtures + the integration/determinism/invariant/regression suites

Dependency direction is strictly core → state → flows → simulator → fixtures.

Ground rules

  1. Determinism is sacred. Never introduce system entropy, wall-clock reads, Hash* state collections, or float math into simulation logic. Randomness flows only through DeterministicRng.
  2. Never silently mutate state. Every applied change goes through the flow pipeline and ends in a recorded, audited StateTransition.
  3. Failed operations must leave no trace. Apply inside TransactionScope; on error, state (including sequence counters) must be bit-identical.
  4. Privacy boundary. Observable output (events, published records) never contains amounts or commitment values. Private values are only reachable through explicitly labeled inspection APIs.
  5. One behavior change, one focused commit, with a message explaining the why. Do not bundle unrelated improvements.
  6. Every bug becomes a fixture. Found a bug? Write the failing test first (crates/fixtures/tests/regression.rs), fix, and — when the bug is reproducible from outside the code — add a failure fixture under fixtures/scenarios/failures/ so it can never return silently.

Scoping your change

This repository is the root of a three-repository system: crucible-prover and crucible-scenarios both compile it, each at a revision pinned in their own manifests. Two consequences follow, and they point in opposite directions.

  • A change here cannot break a consumer. They build the pinned revision, not your branch, so their CI stays green while your change lands. You do not need to coordinate a release to land work in this repository.
  • A change to a published contract blocks the next pin bump. If you alter a signature, a serialized shape, or a state/event semantic that a consumer depends on, that consumer cannot move to your revision until it adapts. Scope such work as two issues — the change here, and a separate follow-up to bump the pin and adapt — never as one PR spanning repositories. A pull request that requires edits in two repositories cannot be reviewed, tested, or reverted as a unit.

See docs/cross-repo-pinning.md for the pinned set and the bump procedure.

Testing

cargo test --workspace                        # everything
cargo test -p crucible-fixtures --test full_lifecycle   # the MVP flow
cargo test -p crucible-fixtures --test determinism      # the defining property
cargo test -p crucible-fixtures --test invariants       # conservation & friends
cargo test -p crucible-fixtures --test regression       # pinned bugs

Fixture corpus changes must keep loader::tests::repository_fixtures_load_and_all_scenarios_replay green — every checked-in scenario must replay.

Review checklist

  • Determinism preserved (see docs/deterministic-execution.md)
  • Atomicity preserved (rollback on failure)
  • Observable output stays value-free
  • Tests added or updated; full workspace green
  • cargo clippy --workspace --all-targets warning-free
  • cargo fmt clean
  • No unrelated changes bundled

Security

Scope

Crucible Simulator is a deterministic testing environment. It contains no production secrets, signs nothing real, and holds no real funds. Its “security” concerns are about correctness of the simulation and the integrity of the testing pipeline — a flaw here could make crucible-prover or crucible-scenarios certify or rely on behavior that does not hold in production.

Threat model

  • State corruption — an operation sequence that leaves partial, inconsistent, or double-spent state. Countermeasures: audited StateTransitions (mismatched records fail with StateCorruption), rollback scopes, and the invariant suite.
  • Commitment reuse / replay — spending the same commitment twice. Countermeasures: Consumed status + nullifier registry; replaying a consumed deposit fails with ConsumedCommitment. Tested as a permanent regression.
  • Fixture poisoning — malformed or version-skewed fixtures. Loader version-checks every fixture; the whole corpus replays in CI.
  • Nondeterministic execution — entropy, wall-clock, iteration-order, or float leakage would break reproducibility and could hide bugs. See docs/deterministic-execution.md; the determinism suite compares full transcripts across runs.
  • Rollback failure — a failed operation leaving partial state or skipped sequence numbers. TransactionScope restores bit-identical state; tested.
  • Privacy leakage — confidential values appearing in observable output. Events are value-free by design; regression tests scan the event stream. Note this simulator stores plaintext values as private simulation state; it is a testing oracle, never a production data store.
  • Incorrect proof-provider handling — flows treat proofs as opaque references and never interpret them; without a provider (or with a rejecting one) transfers fail cleanly with InvalidProof.
  • Local/testnet divergence — the local simulator never requires network access; a future testnet adapter must be optional and explicit.

Reporting

If you find a flaw that could corrupt simulated state, break determinism, permit double-spending in the model, or leak private simulation state:

  1. Do not open a public issue for exploitable findings.
  2. Open a private report, or email the maintainers with full repro: fixture file (or environment + seed + operation sequence) and the observed vs expected behavior.
  3. Reference the security checklist above so the report can be triaged fast.

Non-sensitive findings and design concerns are welcome as normal issues — tag them security so they get the review checklist.

Contributor Covenant Code of Conduct

Our Pledge

We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.

We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.

Our Standards

Examples of behavior that contributes to a positive environment:

  • Demonstrating empathy and kindness toward other people
  • Being respectful of differing opinions, viewpoints, and experiences
  • Giving and gracefully accepting constructive feedback
  • Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
  • Focusing on what is best not just for us as individuals, but for the overall community

Examples of unacceptable behavior:

  • The use of sexualized language or imagery, and sexual attention or advances of any kind
  • Trolling, insulting or derogatory comments, and personal or political attacks
  • Public or private harassment
  • Publishing others’ private information, such as a physical or email address, without their explicit permission
  • Other conduct which could reasonably be considered inappropriate in a professional setting

Enforcement Responsibilities

Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.

Scope

This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces.

Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement. All complaints will be reviewed and investigated promptly and fairly.

Attribution

This Code of Conduct is adapted from the Contributor Covenant, version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.

🔥 Crucible Prover

CI MSRV License Pitch video

PROVE — the zero-knowledge proving layer of Crucible, a three-repository suite for the Stellar Confidential Token architecture.

📖 Documentation site for all three repositories: https://crucible-docs-flame.vercel.app

▶ Watch the pitch — 4:52

Crucible product pitch video

Click the thumbnail to watch the full product pitch. It covers the problem, the three-layer architecture, how proofs are generated and checked, and the limits this project states about itself.

Every frame is a live capture — the deployed documentation site, the public repositories, and the deployed Stellar testnet verifier contract. Nothing in it is a mock-up, and nothing in it is a slide about a roadmap.

Pitch preview

RepositoryLayerResponsibility
crucible-simulatorSIMULATEdeterministic state and execution model
crucible-proverPROVEzero-knowledge proof generation, circuits, verification, proof artifacts
crucible-scenariosSTRESS-TESTscenario orchestration and adversarial testing
crucible-docsREADthe rendered documentation for all three layers — a build of their markdown, not a source of it

The boundary is strict:

crucible-simulator owns the state and execution model. crucible-prover owns proving. crucible-scenarios owns scenario orchestration.

What this repository does

crucible-prover owns the complete proving lifecycle:

Simulator State ─▶ Proof Request ─▶ Witness ─▶ Circuit ─▶ ACIR
      ─▶ Prover Backend ─▶ ZK Proof ─▶ Public Inputs ─▶ Verification
      ─▶ Soroban-Compatible Proof

It is not a wallet, a token contract, a blockchain explorer, a transaction simulator, a compliance or audit engine, a general-purpose ZK framework, a secrets vault, or a Soroban SDK. It is the proof engine and proving infrastructure for Crucible.

Layout

interfaces/    Stable contracts: ProofProvider/Prover/Verifier traits,
               requests/responses, circuit ids, expectations spec
crates/        prover-core, proof-types, witness, artifacts, noir,
               ultrahonk, verifier, mock, vectors
adapters/      Sibling-repo bridges: simulator proof seam (real UltraHonk
               proving) + Soroban on-chain verification payload path
circuits/      The Noir workspace (shared lib, register/deposit/merge/transfer/
               withdraw circuits, measurement gadgets)
artifacts/     Pinned compiled circuits + manifests (the proving input),
               runtime verification-key store
test-vectors/  Cross-language vectors per operation (valid + reject categories)
schemas/       JSON schemas for proofs, requests, witnesses, artifacts
proofs/        Committed proof-envelope fixtures + serialization material
tests/         Cross-crate security/invariant/verification/live suites
benches/       In-process pipeline benchmarks (toolchain-free)
examples/      Runnable end-to-end demos (mock backend)
cli/           Orchestration CLI (no proving logic)
docs/          Architecture and design documents
scripts/       Toolchain setup, gates, and regeneration scripts

Status

The full proving pipeline is implemented and green in CI: interfaces and wire types, witness and artifact management, mock and UltraHonk backends proving only from manifest-pinned artifacts, state-bound circuits, prover-core orchestration, cross-verifier agreement, the vector catalog, committed proof fixtures, benchmarks, examples, and the crucible-prover CLI (whose binary is attached to tagged releases). The mock backend is TEST ONLY and not cryptographically secure.

The canonical end-to-end flow is:

simulator state ─▶ witness builder ─▶ Noir circuit ─▶ ACIR
      ─▶ UltraHonk prover ─▶ ZK proof ─▶ local verifier / Soroban verifier

Backends plug into the ProofProvider interface so the simulator and the scenario suites never couple to UltraHonk — or to the mock prover used in CI.

The Soroban on-chain verification path is live against a deployed testnet verifier contract (adapters/soroban, gated live tests):

  • contract CCS6Z3VVCKV4F5BCH7VXJLKKWMDROUWOTZYROJ4T26CM7R45SE4IFYI2 on Stellar testnet, holding the transfer circuit’s key, deployed in ledger 4569701;
  • reproduce the result yourself — CRUCIBLE_SOROBAN_LIVE=1 cargo test -p crucible-soroban-adapter --test live submits the committed fixture to that contract over read-only RPC simulation, so it needs no key and pays no fee;
  • the full record is in docs/deployment.md and docs/soroban-verification.md.

Remaining workstreams are Merkle membership for consumed commitments and the optional live-network testnet layer — see docs/simulator-integration.md and docs/testnet.md.

Development

cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace

Noir (circuits) is a separate toolchain; see scripts/setup-noir.sh and docs/noir.md.

Documentation

The documentation in this repository is rendered together with crucible-simulator and crucible-scenarios at https://crucible-docs-flame.vercel.app. The markdown files here are the source; the site is a build of them. Rebuilds are nightly and on demand — a push here does not itself trigger one — so a change appears on the site within a day, or immediately if the documentation-site workflow is dispatched. To change a published page, change the file that owns it in this repository.

The pages below are the full index.

Architecture & lifecycledocs/architecture.md (layers, boundaries, dependency rules), docs/proving-model.md (requests, providers, binding, mock), docs/proof-lifecycle.md (stages and failure modes), docs/witness-model.md (private/public split, encoder), docs/public-inputs.md (what proofs bind to), docs/proof-format.md (the envelope wire format), docs/verification.md (verifiers, agreement, round trips).

Privacy & securitydocs/privacy.md (structural secret handling), docs/security.md (guarantees and mechanisms), docs/threat-model.md (adversaries and defenses).

Circuits & backendsdocs/circuit-model.md (operation circuits, boundaries, measured costs), docs/noir.md (Noir toolchain split), docs/ultrahonk.md (real UltraHonk proving with bb).

Ops & toolingdocs/cli.md (full command surface), docs/artifacts.md (pinned artifacts and the provider gate), docs/test-vectors.md (the vector catalog), docs/performance.md (what each benchmark measures), docs/reproducibility.md (the pin chain), docs/compatibility.md (versioning policy), docs/dependency-policy.md (crypto-dependency rules and why sha2 is held at 0.10), docs/deployment.md (releases and production gaps).

Design & roadmapdocs/simulator-integration.md (the simulator boundary and adapter design), docs/soroban-verification.md (on-chain verification groundwork), docs/testnet.md (optional testnet execution layer).

Quick start (CLI)

cargo run -q -p crucible-cli -- circuits list
cargo run -q -p crucible-cli -- circuits compile
cargo run -q -p crucible-cli -- artifacts check
cargo run -q -p crucible-cli -- prove transfer \
  --vector test-vectors/transfer/valid/transfer-valid-001.json \
  --backend mock
cargo run -q -p crucible-cli -- verify transfer-valid-001.mock.proof.json
cargo run -q -p crucible-cli -- vectors run

See docs/cli.md for the full command surface.

License

Licensed under either of Apache License, Version 2.0 or MIT license at your option — the same dual offer crucible-simulator and crucible-scenarios make.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this repository by you, as defined in the Apache License, shall be dual licensed as above, without any additional terms or conditions.

Architecture

crucible-prover is the proof engine of the Crucible polyrepo. It owns zero-knowledge proof generation, verification, proof artifacts, circuit interfaces, and prover backends. The other two polyrepos own their own domains and the boundaries between them are strict:

crucible-simulator owns the state and execution model. crucible-prover owns proving. crucible-scenarios owns scenario orchestration and adversarial testing.

This document describes the internal architecture of crucible-prover and the rules that keep it from becoming a dumping ground.

Repo layout at a glance

interfaces/        stable contracts shared by every crate and sibling repo
crates/
  proof-types/     versioned wire format (ProofEnvelope)
adapters/
  simulator/       simulator proof seam over the real prover (mock + real
                   UltraHonk proving)
  soroban/         on-chain verification payload path (up to the
                   deployed-contract boundary)
  (testnet)        future sibling-repo bridge
  witness/         private witness assembly, encoding, redaction
  artifacts/       pinned circuit artifacts + manifests (proving input)
  mock/            TEST-ONLY deterministic prover/verifier
  prover-core/     provider registry, dispatch, orchestration
  verifier/        verification dispatch + cross-verifier agreement
  noir/            nargo CLI adapter (compile/execute/info)
  ultrahonk/       UltraHonk provider/verifier + VK store + calldata encoding
schemas/           JSON Schema contracts for the wire formats
proofs/            committed proof-envelope fixtures (serialization pins)
benches/           in-process pipeline benchmarks (toolchain-free)
examples/          runnable end-to-end demos (mock backend)
scripts/           check.sh / test-all.sh, gates, and regeneration helpers
docs/              this and the other architecture docs
circuits/          Noir circuit workspace (shared lib, five ops, gadgets)
tests/             cross-crate security/invariant/live suites

adapters/ holds sibling-repo bridges. The simulator proof seam is implemented over the real UltraHonk backend (see docs/simulator-integration.md); the Soroban on-chain verification path is live — wire payload, verifier service registration, a real LiveSorobanClient targeting a deployed testnet verifier contract, and local + network-gated agreement tests (see docs/soroban-verification.md). The testnet bridge lands with its sibling workstream (see docs/testnet.md). The seams every adapter implements live in interfaces/, so no adapter imports another repository’s internals.

The canonical flow

Simulator State
      │
      ▼
Proof Request            interfaces::ProofRequest
      │
      ▼
Witness Construction     crucible-witness (private ↔ public split)
      │
      ▼
Circuit                  circuits/ (Noir workspace)
      │
      ▼
ACIR                     nargo compile (crucible-noir)
      │
      ▼
Prover Backend           ProofProvider impls (mock + UltraHonk/bb)
      │
      ▼
ZK Proof                 ProofResponse / ProofEnvelope
      │
      ▼
Public Inputs            bound to the response
      │
      ▼
Verification             crucible-verifier (local + on-chain agreement)

Layer rules

1. Clients depend on interfaces, never on backends

crucible-simulator and crucible-scenarios depend on crucible-interfaces only:

simulator / scenarios
      │  (ProofProvider / Prover traits)
      ▼
   interfaces
      ▲
      │
 mock · noir · ultrahonk

This is what lets the simulator tests stay fast (mock proofs), prover implementations evolve independently, and scenarios pick mock or real proofs per test depth.

2. The repository is not UltraHonk-specific

UltraHonk is the backend of the current Stellar Confidential Token implementation, but Crucible is the proof engine, not an UltraHonk shim. The ultrahonk crate contains only backend-specific knowledge — format tags, version compatibility, verification-key-id policy, calldata encoding — and plugs in through the same ProofProvider/Verifier traits the mock uses. RISC Zero/Groth16 or any future Stellar verifier architecture plugs in the same way.

3. The circuit/toolchain boundary is explicit

Noir is not “just another Rust crate”. nargo is its own toolchain: it compiles circuits/ into ACIR artifacts independently of Cargo, and crucible-noir is the only crate allowed to execute it. Proof generation and verification are not nargo’s job in current toolchains — they moved to the Barretenberg backend — so the ultrahonk crate consumes crucible-noir for witness solving and its own bb adapter for proving.

Compiled bytecode is pinned: every op’s artifact lives in artifacts/circuits/<op>/ next to a manifest declaring its SHA-256, and the provider proves only from that pinned root (layer rule 4).

4. Integrity before trust

Every compiled artifact is loaded through crucible-artifacts, which refuses to hand out bytes that do not match the artifact’s manifest byte-for-byte. Nothing is ever loaded partially, and no proof is ever accepted from an artifact that failed its checksum.

5. Verification round-trip is mandatory by default

ProverService::prove_and_verify refuses to return a proof that fails against its own response. crucible-verifier goes further: it can run the same proof through every verifier registered for a backend (local, on-chain) and report disagreement instead of assuming equivalence.

6. Privacy is structural

Private witness values cannot be formatted, logged, serialized, or embedded in errors by accident: SecretValue implements no Debug, Display, or Serialize. ProofRequest carries secrets and therefore has no Serialize; its only JSON form is the redacted view. Toolchain stderr is never echoed into errors because compiler diagnostics can contain source snippets with witness values.

What does NOT belong in this repo

  • a wallet, token contract, blockchain explorer, or transaction simulator
  • a compliance, audit, or policy engine
  • a general-purpose ZK framework or production key-management system
  • the entire OpenZeppelin Confidential Token implementation
  • large scenario catalogs, stress/adversarial/concurrency scenarios (those are crucible-scenarios’ job)

crucible-prover answers one question: can I construct and verify the proof for this state transition? crucible-scenarios answers: what happens when I execute hundreds of valid, invalid, adversarial, and pathological transitions?

Repository status

main carries the full proving pipeline: interfaces and wire types, witness and artifact management, the mock and UltraHonk backends with manifest-pinned artifacts, prover-core orchestration, the verification service (with cross-verifier agreement), the Noir circuits with cryptographic state binding, the cross-language vector catalog, committed proof fixtures, benchmarks, examples, and the crucible-prover CLI. Merkle membership for consumed commitments and the scenario layer’s live-network testnet adapter remain designed-but-unbuilt workstreams on top of these seams (see the roadmap docs). Soroban on-chain verification and the simulator adapter are built — the Soroban path is live against a deployed testnet contract (see the adapters section above and docs/deployment.md).

Artifact Management

The proving path must always know exactly which circuit produced this proof. crucible-prover answers that with a pinned artifact root: every compiled circuit sits in the repository next to a manifest declaring each file’s SHA-256, and no component proves against ad-hoc bytecode.

See security.md G4 and threat-model A4 (the artifact swapper) for the adversarial framing.

Layout

artifacts/
└── circuits/
    ├── register/
    │   ├── manifest.json        # declares files + SHA-256s, backend, versions
    │   └── register.json        # compiled ACIR bytecode
    ├── deposit/   …
    ├── merge/     …
    ├── transfer/  …
    └── withdraw/  …

Verification keys produced during proving are written to artifacts/verification-keys/ by the VkStore, keyed by a digest of circuit id + circuit version + artifact checksum — never by an untrusted proof-supplied name.

The manifest

Each manifest.json records the circuit identity (circuit, circuit_version, artifact_version), the backend it was built for, and one entry per file:

{
  "manifest_version": 1,
  "circuit": "transfer",
  "circuit_version": "0.1.0",
  "artifact_version": "0.1.0",
  "backend": "ultrahonk",
  "files": [
    { "path": "transfer.json", "sha256": "7cf88c43…", "kind": "acir" }
  ],
  "backend_metadata": { "generated_by": "crucible-prover/0.1.0" }
}

Generation is deterministic: identical bytecode reproduces byte-identical manifests, which is what makes the CI freshness gate (below) possible.

The provider gate

UltraHonkProvider::generate refuses to prove until the artifact passes crucible-artifacts’ strict loader:

  1. the manifest must parse and its paths must stay inside the artifact directory (no traversal);
  2. every declared file must exist and match its SHA-256 byte-for-byte;
  3. no undeclared file may be present (strict mode).

Only then is a witness solved and bb invoked. Failures map to ProviderError::ArtifactIntegrity (tampered bytes, extra files) or ArtifactUnavailable (missing manifest/bytecode) — always before any proving work, so a swapped artifact is rejected without touching secrets or backend state.

CLI

crucible-prover artifacts check              # verify all five pinned artifacts
crucible-prover artifacts generate           # re-pin from circuits/target (deterministic)
crucible-prover artifacts generate transfer  # re-pin one op
  • check runs the same strict loader the provider runs and exits non-zero listing every problem.
  • generate copies <circuits>/target/<op>.json into the pinned root next to a freshly computed manifest. Requires compiled bytecode (crucible-prover circuits compile).

Keeping artifacts honest

A circuit-source change that forgets to re-pin its artifact is a stale-bytecode hazard: witnesses would be solved against new source while proofs verify against old circuits. CI therefore enforces two gates:

  • artifacts check — the committed artifacts are self-consistent (manifest matches files);
  • a fresh-compile determinism gateartifacts generate into a temp root, then diff -r against the committed artifacts/circuits. Any circuit change that didn’t re-pin fails the build.

The toolchain is pinned to match the committed artifacts (noirup -v 1.0.0-beta.26, bbup -v 6.0.0-nightly.20260903) so fresh compiles are reproducible.

Tested by

  • the live tests/tests/artifacts.rs suite — attacks a copy of the pinned artifact: single-byte bytecode flip, missing manifest, missing bytecode, planted extra file; all must be rejected before proving, and an intact pinned register artifact must still prove a real UltraHonk proof;
  • tests/security/artifact_tampering.rs (mock-tier) and the crucible-artifacts unit suite (traversal manifests, checksum edits).

Internal audit report — Soroban on-chain verification seam

  • Scope: the code this repository controls around on-chain UltraHonk verification: the wire payload and calldata encoders (adapters/soroban/src/payload.rs, crates/ultrahonk/src/calldata.rs), the contract boundary and local double (adapters/soroban/src/contract.rs), the live network client (adapters/soroban/src/live.rs), the bb process layer (crates/ultrahonk/src/exec.rs), the verification-key store (crates/ultrahonk/src/store.rs), and the witness-material handling (crates/witness, adapters/simulator/src/oracle.rs, crates/ultrahonk/src/provider.rs).
  • Out of scope: the cryptographic core of the deployed verifier contract, which is Nethermind’s audited rs-soroban-ultrahonk (their audit, their VK parsing, their BN254 host-function usage). This report treats that contract as a trusted boundary and audits our seam to it.
  • Methodology: source review of every owned module, static dependency audit (cargo audit), and live on-chain invariant verification against the deployed testnet contract CCS6Z3VVCKV4F5BCH7VXJLKKWMDROUWOTZYROJ4T26CM7R45SE4IFYI2.
  • Date: 2026-09-08. Result: no critical or high findings.

1. Dependency audit

cargo audit against the RustSec advisory database (1242 advisories, 144 locked dependencies):

CategoryCount
Vulnerabilities0
Unmaintained0
Unsound0
Yanked0
Informational0

The pinned toolchains are additionally pinned by exact version (nargo 1.0.0-beta.26, bb 6.0.0-nightly.20260903, Rust 1.98) and checked at runtime, so a supply-chain substitution is detected by the version gates before any proving work.

2. Findings

F-1 (Low, fixed in this audit) — RPC client had no request timeout

LiveSorobanClient::post issued ureq::post with no .timeout(). ureq 2.x defaults to no timeout, so a hung RPC endpoint would block the caller indefinitely — relevant for an interactive verification path. Fixed: a 30s deadline covering the whole request (connect + write + read + body) is now set, and a timeout surfaces as LiveError::Rpc like any transport failure.

F-2 (Low, fixed in this audit) — provenance version drift in public_inputs.json

write_public_inputs_json hardcoded TESTED_BB_VERSION in the document’s bb_version field, while proof.json and vk.json carry the resolved key’s bb_version. With the current single toolchain the values coincide, but a store holding a key produced by a different bb version would emit three artifact files with mutually inconsistent provenance — exactly the drift class a verifier exists to catch. Fixed: the document now carries the resolved key’s bb_version, so the three files always agree.

F-3 (Info, accepted) — set_permissions failure is ignored in the oracle

adapters/simulator/src/oracle.rs writes the private Prover.toml and applies mode 0600 with let _ = on the set_permissions result. The enclosing scratch directory is created by tempfile::tempdir (0700, owner-only), so the file is unreachable by other users even if the chmod fails; the failure is only relevant on exotic filesystems. Accepted as-is with the tempdir boundary as the actual control.

F-4 (Info, by design) — error surface is deliberately small

The deployed contract exposes exactly six error codes; the off-chain layers surface ~70 typed variants. See docs/error-surface.md for the inventory and the 1:1-code-to-failure-mode policy. No changes made — padding codes would be inventory inflation, not hardening.

3. What was verified (live, against the deployed contract)

All checks were run against the live testnet contract through the production client code (CRUCIBLE_SOROBAN_LIVE=1 cargo test -p crucible-soroban-adapter --test live):

InvariantResult
Legit transfer fixture proof verifies on-chainOk(())
Tampered proof (1 byte flipped) rejectedError(Contract, #4) = VerificationFailed
Wrong-length proof rejected before crypto✅ length gate (Error #3)
On-chain VK equals committed fixture, byte-for-byte (1760 B)
vk_bytes() returns the committed VK from instance storage
Constructor re-initialization rejected✅ by source (AlreadyInitialized, deploy-only fn)
Cost oracle returns the RPC’s resource estimate✅ (verify_proof_with_cost)
Network healthy at audit time (ledger 4,571,410)

The on-chain VK check is cryptographically meaningful: the legit fixture verifies only because the contract holds exactly the committed VK — a different key would fail verification.

4. Defense-in-depth summary (owned layers)

  • Payload (payload.rs): versioned (PAYLOAD_VERSION=1), bounded (255-byte names, 4 GiB proof cap), fully validated on decode (checked-add cursor, UTF-8 names, calldata well-formedness), deterministic.
  • Calldata (calldata.rs): ABI-ordered, count-prefixed, 32-byte field words, rejects truncation and out-of-width values; decode rejects non-field values (≥ BN254 modulus) — a malformed submission can never become a plausible one.
  • bb process layer (exec.rs): paths-only execution (witness never read into the module), scheme/version provenance validation on every artifact, VK-digest cross-check between proof and vk documents, redacted one-line stderr excerpts (no witness material, 200-char cap).
  • VK store (store.rs): id-scheme parsing (uhk/circuit/version/hash), foreign ids hashed into a separate namespace (no collisions, no escape), scheme guard on write, malformed stored keys reported distinctly from missing keys.
  • Privacy: witness material lives only in 0600 files inside tempfile scratch dirs that are deleted when generation returns; errors carry paths and counts, never values.
  • Live client (live.rs): one RPC call per verification (simulation — no submission, no fees, no secrets), strkey types validated on both addresses, every malformed-response shape maps to a typed error, and now a hard 30s deadline.

5. Residual risks (external by nature)

  1. Mainnet deployment requires a funded mainnet account and a key-management review; testnet is fully covered.
  2. Toolchain divergence: the audited verifier accepts bb v0.87.0 proofs; the local backend emits bb 6.0.0-nightly proofs. The on-chain fixtures pin the on-chain format, but proofs from the local toolchain must be re-proven with the on-chain pin before submission. Tracked in docs/soroban-verification.md.
  3. Future verifier upgrades should re-run this audit’s live suite against the new deployment before trusting it.

6. Verification commands

cargo audit
CRUCIBLE_SOROBAN_LIVE=1 cargo test -p crucible-soroban-adapter --test live
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings

Circuit model

The Noir circuits that give the proving layer its statements: what each operation proves, where the public/witness boundary sits, the invariants they enforce, and their measured cost.

The state model the circuits operate on

Confidential value lives in commitments on the ledger:

commitment = Pedersen(COMMITMENT_DOMAIN, amount, blinding)

The ledger stores commitments; amounts and blindings never appear on it. An account is a public address = key-hash of a secret key; the secret is the only thing that can spend the account’s commitments.

Operations consume old commitments (emitting a nullifier so they cannot be spent twice) and produce new ones. Every nullifier binds the commitment, the owner secret, and — for value-moving operations — the token, so a proof cut for one token cannot be replayed against another.

public:  token, addresses, commitments being consumed/produced
private: account secret, amounts, blindings

Operation circuits

register

Proves ownership of a fresh account: account_address = key_hash(account_sk).

  • public: account_address
  • private: account_sk
  • emits: nothing (no state consumed)

deposit

Adds confidential value to an owned commitment.

  • public: token_address, account_address, old_commitment
  • private: account_sk, old_amount, old_blinding, amount, blinding
  • proves: ownership; old_commitment opens to (old_amount, old_blinding); amount in range; emits a token-bound nullifier.
  • returns: new commitment over old_amount + amount

merge

Consolidates two owned commitments into one.

  • public: token_address, account_address, commitment_a, commitment_b, root_hi, root_lo
  • private: account_sk, both openings, blinding
  • proves: ownership; both commitments open to their witness; emits two token- and state-bound nullifiers (root halves folded in).
  • returns: merged commitment over amount_a + amount_b

transfer

Moves confidential value from the sender to the recipient.

  • public: token_address, sender_address, recipient_address, old_sender_commitment, root_hi, root_lo
  • private: sender_sk, amount, sender opening, recipient_blinding, change_blinding
  • proves: sender ownership; the sender’s commitment opens to old_amount for the sender address; amount <= old_amount (no overdraw); amount in range.
  • returns: recipient commitment over amount bound to the recipient address, change commitment over old_amount - amount bound to the sender address, token- and state-bound nullifier.

Recipient binding is enforced in-circuit: every commitment embeds its owner’s address (commit(amount, blinding, owner)), so the produced recipient commitment can only be spent by a proof that claims the recipient address — a sender who keeps the recipient blinding cannot later spend it under their own address (see docs/security.md).

withdraw

Redeems confidential value out of the domain.

  • public: token_address, account_address, commitment, root_hi, root_lo
  • private: account_sk, amount, old_amount, old_blinding, change_blinding
  • proves: ownership; the commitment opens to old_amount; amount <= old_amount; emits a token- and state-bound nullifier.
  • returns: change commitment over old_amount - amount, nullifier.

Invariants enforced in-circuit

  1. Ownership — only the account secret passes the address assertion.
  2. No overdrawspend <= balance (bounded u64 comparison).
  3. Range — every amount is constrained to 63 bits before arithmetic, so field wraps cannot mint value.
  4. Opening consistency — a consumed commitment must open to the witness provided; the ledger-facing commitment is bound to the transition.
  5. Owner binding — every commitment’s preimage includes the owner address, so a spend must claim the same address the commitment was created for; a commitment attached to one address cannot be spent as another’s, even by someone holding its blinding.
  6. Conservation — value in the produced commitments equals value out of the consumed ones (commitments are computed, not chosen).
  7. Double-spend protection — consuming a commitment emits a nullifier.

State-bound operations (merge, transfer, withdraw) scope their nullifiers to token and state: the two halves of the ledger state root are public inputs folded into every emitted nullifier, so a proof cut for root A is cryptographically rejected against root B (see docs/ultrahonk.md). Tree membership of the consumed commitments in that root is a separate statement (it needs an inclusion proof) and lands with the membership workstream; register and deposit remain state-unbound by design.

Scope honesty

These circuits implement the shape of the Confidential Token semantics so the proving architecture can be exercised end to end, but the scaffold must be aligned with the real specification before production use:

  • commitment layout and hash (Pedersen via the Noir stdlib here),
  • key derivation and address format,
  • nullifier construction (including the exact token/domain folding),
  • whether merge/withdraw exist as separate operations in the target design.

Each deviation is flagged at its definition site in circuits/lib/. The test vectors in circuits/*/testdata/ and the JSON fixtures are derived from these scaffold semantics and will need regeneration on alignment.

Measured cost (nargo 1.0.0-beta.26)

nargo info per operation circuit (main function, ACIR / Brillig opcodes) and per measurement gadget:

PackageACIRBrilligNotes
register1044ownership only
deposit9661consume + produce + range
merge12461two consumes + produce
transfer11861consume + two produces + overdraw
withdraw10061consume + produce + overdraw
gadget_commitment1844one Pedersen commitment
gadget_hash1844one two-field hash
gadget_range161763-bit amount range check
gadget_ownership1144address ownership assertion
gadget_state6244full consume-and-produce transition

The gadget rows are the reference points for reasoning about circuit cost: the value-moving operations sit just above gadget_state plus one range check per additional amount, while register is essentially gadget_ownership. Regenerate with nargo info --workspace; treat these as drift canaries, not fixed promises, until a backend pins real constraint counts.

CLI

crucible-prover is the command-line face of the proving engine. It is pure orchestration: every command shells out to the library crates and keeps no proving logic of its own. Circuit compilation runs through the crucible-noir adapter (never raw nargo process calls), proving through ProverService, and verification through the registered verifiers.

Building

cargo build -p crucible-cli
# binary at target/debug/crucible-prover

or without building:

cargo run -q -p crucible-cli -- <command>

Commands

circuits

crucible-prover circuits list            # the five operation circuits + artifact status
crucible-prover circuits check           # every op must have a compiled, parseable artifact
crucible-prover circuits compile         # compile all five (requires nargo on PATH)
crucible-prover circuits compile transfer

list reports each operation circuit’s ACIR artifact path, size, and SHA-256. check exits non-zero listing every missing or unparseable artifact. compile runs nargo compile through the toolchain adapter, which also enforces the supported nargo major version.

artifacts

crucible-prover artifacts check              # verify all five pinned artifacts
crucible-prover artifacts check --root /path # against another root
crucible-prover artifacts generate           # re-pin all five from circuits/target
crucible-prover artifacts generate transfer  # re-pin one

The proving path only ever consumes the pinned artifact root (<repo>/artifacts/circuits/<op>/): each op’s compiled bytecode sits next to a manifest.json declaring its SHA-256, and the provider strict-loads it (see docs/security.md G4) before a single byte is touched. check runs that same strict loader over every pinned artifact and exits non-zero on any integrity problem. generate re-pins artifacts from the current compiled bytecode (run circuits compile first) and is deterministic — identical bytecode reproduces byte-identical manifests. A circuit change that forgets to re-pin fails CI via the fresh-compile diff gate.

artifacts inspect

crucible-prover artifacts inspect          # all five, one report each
crucible-prover artifacts inspect transfer # one op
crucible-prover artifacts inspect transfer --root /path

Prints one pinned artifact’s full provenance — circuit, circuit and artifact versions, backend, verification-key id, backend metadata, and each declared file’s role, byte size, and SHA-256 — after running it through the same strict loader as check. A tampered artifact reports FAIL with the reason and still prints the raw manifest for diagnostics; the exit code is non-zero when anything failed. This is the artifact inspect surface for answering which circuit produced this proof (see docs/artifacts.md).

witness build

crucible-prover witness build transfer \
  --vector test-vectors/transfer/valid/transfer-valid-001.json
crucible-prover witness build transfer \
  --vector test-vectors/transfer/valid/transfer-valid-001.json \
  --out /tmp/transfer.Prover.toml

Assembles the circuit witness a vector describes and shows what the circuit will see: every public value in full, every private value as a redacted name. With --out it writes the Noir Prover.toml layout through crucible-witness’s restricted encoder (0600 on Unix) — the only place private values leave memory — for hand-off to a toolchain or debugging. The summary path never prints a private value.

prove

crucible-prover prove transfer \
  --vector test-vectors/transfer/valid/transfer-valid-001.json \
  --backend ultrahonk \
  --out transfer.proof.json

Loads a test vector (see docs/test-vectors.md), assembles the [ProofRequest] for the chosen backend, and proves through [ProverService]. The local round-trip must pass before an envelope is written — a proof that fails its own verification is never returned. For --backend ultrahonk, the bytecode is loaded from the pinned artifact root, not ad-hoc paths; a missing or tampered artifact is refused before any witness is solved.

  • --backend mock (default): fast, TEST ONLY, no toolchain required.
  • --backend ultrahonk: real UltraHonk proofs. Requires nargo and bb on PATH (see scripts/check-bb.sh) and compiled bytecode under circuits/target/ (run circuits compile first).
  • --out defaults to <vector-id>.<backend>.proof.json in the current directory.
  • --vk-store <dir>: verification-key store for ultrahonk, default <repo>/artifacts/verification-keys (created on demand).

The printed summary names the request id, circuit version, backend, verification-key id, public-word count, and the state root the proof is bound to. The envelope JSON is the unit of storage and exchange.

verify

crucible-prover verify transfer.proof.json
crucible-prover verify transfer.proof.json --vk-store /path/to/store

The envelope is self-describing (backend, verification-key id, public outputs, state reference), so the command dispatches to the matching verifier with no user hints: mock envelopes go to MockVerifier, ultrahonk envelopes to UltraHonkVerifier resolving the key from the store. Exit 0 prints the verified summary; every rejection exits 1 with the reason — tampered bytes, changed public outputs, wrong key, stale state, or a structurally stripped binding.

vectors run

crucible-prover vectors run               # judge the whole catalog (mock tier)
crucible-prover vectors run --op transfer # one operation
crucible-prover vectors run --catalog /path/to/vectors

A fast, toolchain-free catalog gate mirroring the integration suite’s mock-tier semantics exactly: vectors expected to verify must round-trip and verify; rejecting vectors must still be well-formed, provable requests. Non-zero exit on any failure. The nargo-gated circuit tier (real witness solving against the circuits) runs via cargo test -p crucible-tests --test vectors.

benchmark

crucible-prover benchmark transfer                 # mock, 3 iterations
crucible-prover benchmark transfer --backend ultrahonk
crucible-prover benchmark transfer --iterations 10 --vector path/to/vector.json

Times the proving pipeline phases for one operation’s valid witness: prove, local verify, and envelope serialization, reported as average / min / max over --iterations runs, alongside proof and envelope byte sizes and the bound state root. The default vector is the operation’s committed valid fixture; pass --vector to benchmark another. With --backend mock this measures orchestration overhead only and says so in its output; with --backend ultrahonk it times real UltraHonk proving through bb (requires the toolchains and pinned bytecode, like prove). For in-process, statistically repeated measurements see the benches/ harness.

Path overrides

All defaults resolve relative to the repository root:

FlagDefault
--circuits <dir><repo>/circuits
--catalog <dir> (vectors run)<repo>/test-vectors
--vk-store <dir><repo>/artifacts/verification-keys
--root <dir> (artifacts)<repo>/artifacts/circuits

Exit codes

CodeMeaning
0success (or verify: proof accepted)
1runtime error — including verification rejections and failed checks
2argument/usage error (clap)

Privacy

Witness material flows from the vector file into the request and then, for ultrahonk, into a 0600 scratch Prover.toml via the witness encoder — it is never echoed in CLI output, and errors never carry witness values. The proof envelope is public by design (it contains only the proof, public inputs, and provenance).

Scope

The CLI does not contain: a wallet, a token contract, a simulator, or a key-management system. It is the orchestration surface for the crucible-prover pipeline: circuits, artifacts, witnesses, proofs, verification, and the vector catalog. Soroban on-chain verification therefore lives in adapters/soroban rather than here — it is implemented and live against a deployed testnet contract, and the crucible-prover binary does not drive it (docs/soroban-verification.md). The live-network testnet adapter is a separate, opt-in workstream (docs/testnet.md).

Compatibility

Versioning policy across the things that must agree for a proof to be produced, stored, and verified: the wire format, the circuit/artifact identity, the toolchain pairing, and the fixtures that pin them.

Versioned surfaces

SurfaceVersion carrierPolicy
Proof envelopeinteger version (current 1)parsing rejects versions newer than known; never guess
Artifact manifestmanifest_version (current 1) + artifact_versionloader rejects unknown manifest schemas; artifact_version bumps on recompile without circuit change
Circuitcircuit_version (0.1.0)a circuit change bumps it; backends gate on it
BackendBackendId string (mock, ultrahonk)open string set so new architectures (RISC Zero/Groth16) need no interface change
Proof encodingProofFormat tag (mock-envelope-v1, ultrahonk-v1)names the encoding; bytes stay backend-owned
Verification keyVerificationKeyIdderived from circuit + version + artifact checksum
Toolchain pairingBACKEND_COMPAT matrix(nargo, bb, circuit_version) rows validated in CI

Rules that keep surfaces compatible

  1. Old tooling must fail loudly, not misread. Envelope parsing rejects version > 1; manifest parsing rejects unknown schema versions; backend version gates refuse unvalidated nargo/bb pairings (docs/ultrahonk.md).
  2. Identity travels with the proof. A ProofEnvelope names circuit, versions, backend, vk id, artifact checksum, and state reference, so a consumer never guesses what produced a proof (docs/proof-format.md).
  3. Version bumps are multi-surface. Changing a circuit, the nargo pin, or the bb pin requires updating: the circuit source + expectations spec, the relevant TESTED_* constant and BACKEND_COMPAT, the installer scripts/CI pins, and re-pinning artifacts + regenerating fixtures — the fresh-compile determinism gate fails until they all agree (docs/reproducibility.md).
  4. Calibration lands as a new version, not a mutation. The calldata encoder carries a format-version byte so on-chain layout calibration can ship without breaking stored fixtures (docs/soroban-verification.md).

Cross-version material

proofs/fixtures/ (envelope v1 JSON, judged by tests/tests/proof_fixtures.rs) and test-vectors/ (judged by the two-tier runner) are the committed regression nets that catch accidental format drift. Compatibility tests for new envelope versions belong next to those nets: commit a v1 fixture, add the v2 parser, prove the v1 fixture still parses and the v2 gate rejects old tooling.

Test coverage

The project tracks line and region coverage across the whole workspace (libraries, adapters, and CLI). Coverage is a floor, not a target: the CI gate and the honest bar is ≥ 80% overall line coverage, with the crypto, verifier, and adapter modules held to the same standard as everything else.

Current status

Measured with cargo-llvm-cov over the full workspace (all targets, excluding generated test-vectors/ fixtures and the committed on-chain proof artifacts):

MetricValue
Line coverage87.5%
Region coverage88.9%
Function coverage77.5%

The low-function figure is an artifact of counting: CLI command entry points, trait impls, and error-variant constructors drag the function average down while the code paths that matter (crypto backend, verifier routing, adapter translation, witness handling) sit in the 85–100% band. The per-module table at the bottom of a cargo llvm-cov report run shows the detail.

How to reproduce

cargo install cargo-llvm-cov          # once
cargo llvm-cov --workspace --all-targets \
  --ignore-filename-regex '(target/|test-vectors/|onchain/)'

Measure with the toolchain on PATH. The CI gate runs in the noir circuits job, where nargo and bb are installed, so the real-crypto suites (UltraHonk round trips, the simulator e2e, the Soroban agreement suite) count toward the measurement. Run without them (cargo test --workspace alone) and those suites are skipped, dropping the reading below the floor even though no production code regressed — the gate is a floor on the full-toolchain number, not the fastest number.

What the coverage buys

  • Real-crypto suites run in CI — the noir circuits job executes the live UltraHonk round trips (oracle → witness → bb provebb verify), so the 87% figure includes proofs that actually verify, not mocks.
  • Error paths are first-class — every error variant in docs/error-surface.md is raised by at least one test: structural rejections (wrong key, wrong circuit, missing state binding) are asserted without needing a toolchain, and toolchain-gated paths are covered by the gated live suites.
  • The weak modules are the honest ones — the local network client (live.rs) sits just above 74% because its failure branches (malformed RPC responses, strkey type mismatches) are unit-tested while the network happy path is gated behind CRUCIBLE_SOROBAN_LIVE=1; the CLI sits in the 65–95% band because its hard paths (real bb proving) need the full toolchain, which the CI circuits job supplies.

Adding coverage responsibly

More tests are welcome; padding is not. A test that asserts a branch without changing what would break if the branch regressed adds inventory, not safety. When adding a feature, cover:

  1. the happy path (one round trip),
  2. each structural rejection the feature can produce (one per error variant),
  3. the boundary conditions (empty input, maximum input, wrong type).

Then re-run the measurement above and confirm the module’s coverage did not regress below 80%.

Dependency policy

This repository implements cryptography, so its dependency graph is part of its attack surface. The rules below exist to keep that surface small and reviewable, and to make the reasoning behind a pin visible instead of implicit.

Two rules

  1. One implementation of a cryptographic primitive per lockfile. If two versions of the same hash or signature crate resolve into the same build, the binary contains two implementations of the same primitive. That doubles the code that must be reviewed, makes “which one produced this digest” ambiguous at the call site, and means a fix in one does not reach the other. Prefer a single resolved version even when that means staying on an older release.
  2. A hash-dependency bump must be justified by known-answer tests, not by a green suite. “All tests pass” is not evidence that the digest is unchanged, because almost every test asserts a relationship between digests (equal inputs agree, unequal inputs differ) rather than a value. Such a suite stays green if the digest changes wholesale. Known-answer vectors computed independently of this code are the only evidence that survives.

Current state: sha2 is deliberately held at 0.10

sha2 0.11 is released, and neither repository should move to it yet. stellar-xdr 28 — the Stellar XDR crate this repository needs for the Soroban boundary — requires sha2 0.10, and so does the pinned crucible-simulator revision this repository compiles. Raising this repository’s own sha2 to 0.11 therefore resolves two sha2 versions, and two digest generations (0.10 and 0.11), into a single build. That is rule 1 violated to gain nothing: the digest output is byte-identical either way.

Evidence, reproducible from a checkout:

$ sed -i 's/^sha2 = "0.10"$/sha2 = "0.11"/' Cargo.toml
$ cargo update -p sha2
      Adding sha2 v0.11.0
$ grep -A1 'name = "sha2"' Cargo.lock | grep version
version = "0.10.9"
version = "0.11.0"
$ grep -c 'name = "generic-array"' Cargo.lock
1
$ cargo tree -i sha2@0.10.9
sha2 v0.10.9
├── crucible-core v0.1.0 (https://github.com/Crucible-TDA/crucible-simulator?rev=…)
└── stellar-xdr v28.0.0

Note also that generic-array 0.14 stays in the tree regardless, because it arrives through the same upstream that holds sha2 back — so the bump does not even reduce the dependency count.

Revisit when stellar-xdr and the pinned simulator both accept sha2 0.11. At that point the bump is a one-line change, and the acceptance test is that the known-answer vectors below still pass unchanged.

What protects the hash surface today

Both repositories now pin their digests with known-answer vectors computed with an independent implementation (Python’s hashlib), so a change in the hash, the encoding, or the domain separator fails loudly rather than silently rewriting persisted state:

RepositoryVectorsPins
crucible-provercrucible-interfaces (proof_provider::proof)ArtifactChecksum::from_bytes over fixed byte strings
crucible-provercrucible-artifacts (checksum)the whole-tree digest over a fixed entry list, plus a test asserting the result is not a hash of a hash
crucible-simulatorcrucible-core (hashing)hash_bytes / hash_str over fixed domains and payloads, plus a test asserting the digest is not a bare SHA-256 of the value

Any change to these constants in a diff is a change to a wire or state format and must be justified as such.

Deployment

What shipping this repository actually means today: the release gate, the artifact that ships, and what a production deployment would still need. This is intentionally short and honest — most of this repo is proving infrastructure, not a deployed service.

Releases

Cutting a tag (v*) triggers .github/workflows/release.yml:

  1. The gate — fmt, clippy, schema validation, and the full workspace test suite must pass, then a release build (cargo build --release).
  2. The artifact — the crucible-prover CLI binary is attached to the tag’s GitHub release (creating the release object when a pushed tag has none). Only tags whose tree passes the whole gate ship a binary.

The CLI is the deployment surface: circuits, artifact pinning, proving, verification, vector judging, benchmarking (docs/cli.md). It is orchestration only; it performs no key management and holds no secrets.

What a checkout carries

  • Pinned artifactsartifacts/circuits/<op>/ bytecode + manifests are committed and strict-loaded before any proving (docs/artifacts.md).
  • Verification keys — written at runtime into the VK store (artifacts/verification-keys/, created on demand by prove / verify). Keys are derived by bb during proving and resolved by id during verification; they are not committed today.
  • Fixtures and vectors — committed catalog + envelope material used by tests and tooling, not shipped to end users.

Deployed on testnet

An UltraHonk verifier contract is live on Stellar testnet, and this repository verifies proofs against it:

ContractCCS6Z3VVCKV4F5BCH7VXJLKKWMDROUWOTZYROJ4T26CM7R45SE4IFYI2
Networktestnet (Test SDF Network ; September 2015)
Verification keythe transfer circuit’s, fixed at deploy
Deploy tx2f821d072a241410ad92d29d14eb71a03dc4726b02df35ce73e94a8621ea7e52 (ledger 4569701)
Verified-proof tx5ef50bffa8a89914a987c1d06b10fa096a31db72af61d861fd751cdaad653e79 (ledger 4569705)

The contract is Nethermind’s audited rs-soroban-ultrahonk wrapper, not code from this repository. Reimplementing UltraHonk verification on-chain would replace audited cryptography with unaudited code for no gain, so this repository owns the integration instead: the wire payload, the verifier service registration, the real LiveSorobanClient, and the agreement tests (docs/soroban-verification.md).

Verify it from a checkout, with no key and no fee:

CRUCIBLE_SOROBAN_LIVE=1 cargo test -p crucible-soroban-adapter --test live

That suite submits the committed on-chain fixture to the deployed contract through Soroban RPC simulation, and asserts the pristine proof is accepted while a tampered one is rejected. Simulation is a real execution of the contract and is the definitive verdict for a read-only call; it just does not settle on-chain.

Production gaps (by design)

  • The on-chain path is testnet-only, and the verifier is a third party’s. Mainnet deployment needs a mainnet-funded account and an audited key-management story. Stellar labels Confidential Tokens a developer preview — the contracts and verifier are under audit and not intended for production use (docs/testnet.md).
  • Only the transfer circuit has on-chain fixtures. Register, deposit, merge and withdraw follow the same recipe when on-chain verification is needed for them, so the live path currently proves the seam is correct rather than covering every circuit.
  • The local and on-chain toolchains emit different proof layouts. The contract is pinned to bb 0.87.0 (456-word proofs, 1,760-byte VK) while the local backend tracks bb 6.0.0-nightly (458 words, 3,680 bytes). The committed on-chain fixture is produced on the contract’s pin, so the two formats are each tested on their own terms rather than assumed interchangeable (docs/soroban-verification.md).
  • The exact circuit scheme is scaffold-shaped until aligned with the Confidential Token circuit specification (docs/circuit-model.md); artifacts, keys, and proofs produced before that alignment must not be treated as final.
  • No transaction-submitting testnet automation. Proof verification runs over read-only RPC simulation, so nothing in ordinary CI signs or submits a transaction, and the scenario layer’s live-network adapter stays opt-in and out of the default pipeline (docs/testnet.md).

Error surface

This document inventories every distinct failure mode the project surfaces, where it is defined, and how it is reported. The inventory is deliberately complete and deliberately small: each error variant maps 1:1 to a real failure mode that a caller can observe and react to. There are no reserved, unreachable, or decorative codes.

Why the count is what it is

A common review checklist asks for “many error codes” as a proxy for robust failure handling. The number itself is not a quality signal — reachable, distinct, precisely-reasoned failure modes are. Padding an error enum to hit a target count produces codes nothing can raise and nothing can handle, which a serious reviewer treats as exactly what it is: inventory inflation.

The project’s policy, enforced by construction:

  1. 1:1 with failure modes — every variant corresponds to a code path that actually produces it, and every code path has a test.
  2. Precise, not verbose — where the cause differs (tampered proof vs. wrong key vs. unavailable backend), the code differs. Where only the value differs (which byte is wrong), the code is the same and the message carries the detail.
  3. No reserved codesInteral-style variants exist only where an invariant genuinely failed and the caller can only react by surfacing the error. They are still raised by real paths.
  4. On-chain codes mirror the audited contract — the deployed verifier is an audited third-party contract with 6 error codes. We do not wrap or renumber them; we preserve and document them.

The deployed on-chain verifier (6 codes)

The testnet contract (CCS6Z3VVCKV4F5BCH7VXJLKKWMDROUWOTZYROJ4T26CM7R45SE4IFYI2) is Nethermind’s audited rs-soroban-ultrahonk wrapper. Its entire error surface is:

CodeNameRaised when
1VkInvalidLengthVK byte slice has the wrong length
2VkInvalidParametersVK header has out-of-range structural parameters
3ProofParseErrorProof byte slice has the wrong length (not 456 words)
4VerificationFailedCryptographic verification rejected the proof
5VkNotSetverify_proof called before the VK was initialized
6AlreadyInitialized__constructor called a second time (VK is immutable)

These six are the complete failure surface of a stateless verifier whose only state is one immutable VK. There is no seventh failure mode to invent.

Off-chain verifier reasons (9)

The local verifier reports why a proof failed, mirroring the mock:

ReasonMeaning
InvalidProofProof bytes did not verify under the given key
PublicOutputMismatchPublic outputs differ from what the proof commits to
StateReferenceMismatchProof is bound to a different state root (stale/replay)
WrongVerificationKeyProof was produced under a different VK (id unresolvable)
CircuitMismatchProof is for a different circuit
VersionMismatchProof is for a different circuit version
ArtifactChecksumMismatchArtifact that produced the proof differs from the pinned one
BackendMismatchProof format does not match the verifier
MissingStateBindingProof requires a state binding the request lacks

Backend adapter errors (per crate)

Every crate exposes its failure modes as a typed enum. The complete inventory:

CrateVariants
crucible-artifactsMalformedManifest, UnsupportedManifestVersion, MissingFile, UnexpectedFile, ChecksumMismatch, IntegrityMismatch, UnsafePath, ReadFailure (8)
crucible-noirBinaryNotFound, UnsupportedVersion, VersionParse, CommandFailed, ExpectedOutput, Io, MalformedArtifact (7)
crucible-ultrahonkEncode, Truncated, BadVerificationKeyId, UnsupportedVersion, BinaryNotFound, UnsupportedBbVersion, VersionParse, MissingFile, Io, Spawn, CommandFailed, MalformedArtifact, InconsistentArtifacts (13)
crucible-witnessMissingRequired, Overlap, OperationMismatch, InvalidValue, Io, Encoding (6 + 2 side markers)
crucible-verifierUnknownBackend, VerifierFailed, Internal (3)
crucible-prover-coreUnknownBackend, UnsupportedCircuit, InvalidRequest, Generation, Envelope, NotVerified, NoVerifier, Internal (8)
crucible-proof-typesUnsupportedVersion, Encoding (2)

Interface-layer errors (interfaces crate)

TypeVariants
ProviderErrorInvalidRequest, UnsupportedCircuit, ArtifactUnavailable, ArtifactIntegrity, BackendUnavailable, ProofGeneration, Internal (7)
VerifierErrorUnsupportedVerifier, InvalidRequest, VerificationUnavailable, Internal (4)
ProverErrorNoProviderAvailable, Provider, VerificationFailed, NotVerified (4)
WitnessErrorMissingWitness, MissingPublicInputs, MissingStateReference, StateRootMismatch (4)

Soroban adapter errors

TypeVariants
ContractErrorBackendUnavailable, VkStore, VerificationRun (3)
LiveErrorInvalidContractAddress, InvalidSourceAccount, Rpc, MalformedResponse, Encode (5)
PayloadErrorMalformed, UnsupportedVersion, Encode (3)

Total

Summing the tables: ~70 distinct typed variants across the stack, every one raised by a real, tested code path, plus the 6 on-chain codes and 9 verifier reasons. This is the honest, complete error surface of the project.

Where the boundary is drawn

The gap between this inventory and a padded target (e.g. “at least 300 codes”) is not something to close by fabrication. A code no code path raises is dead inventory; a code a caller cannot react to differently is noise. If a reviewer requires more granularity, the productive direction is finer verifier reasons or on-chain payload classes — not synthetic error-code ranges — and this document is the place the inventory is kept honest.

On-chain gas and fees

Measured cost of verifying one UltraHonk proof through the deployed Soroban verifier contract on the Stellar testnet, what drives it, and the levers we control (and the ones we do not).

Per-transaction cost (measured on testnet)

Contract: CCS6Z3VVCKV4F5BCH7VXJLKKWMDROUWOTZYROJ4T26CM7R45SE4IFYI2 (transfer-circuit VK — see docs/soroban-verification.md).

CallOutcomeminResourceFeeFee actually chargedHost instructions
verify_proof — valid transfer proof✅ verified149,208 stroops (0.014921 XLM)134,418 stroops (0.013442 XLM)95,297,789
verify_proof — tampered proof❌ rejected (VerificationFailed)0 (never lands)early exit before crypto
verify_proof — wrong-length proof❌ rejected (ProofParseError)0 (never lands)early exit before crypto
verify_proof — wrong public inputs❌ rejected0 (never lands)early exit before crypto

The simulated minResourceFee (149,208) is the RPC’s own fee estimate and tracks the actual charge (134,418) closely; the estimate carries a safety buffer, and the ledger charges only the resources actually used. Because verify_proof is read-only, the simulation estimate is also the exact cost any submission would carry — you never need to submit to know the price.

Where the fee goes

Soroban fees are resource-based: instructions, ledger I/O, and memory. The measured resource footprint of one verification:

ResourceMeasuredNotes
Instructions95.3 M~100% of the fee. The UltraHonk verification: 28 sumcheck rounds × 26 subrelations, the Shplemini batch opening (65-entry MSM), and the final KZG pairing, all on BN254 via the host’s bn254 crypto functions.
Disk read0 BVerification is read-only.
Write0 BThe verifier writes nothing.
Ledger keys2 readThe VK (1,760 B) read from instance storage.

There is no ledger I/O cost to optimize — the fee is almost entirely CPU (the 95 M instructions above). The proof itself (14,592 B) and public inputs (288 B) travel as invocation arguments; at ~10⁷ stroops per 100 M instructions, argument bytes are noise next to the crypto.

The cheap-fail property (the caller-side optimization that matters)

The contract validates before it computes: proof length is checked first (ProofParseError), then the VK is loaded, then — and only then — the 95 M-instruction cryptography runs. Consequences:

  • Tampered, truncated, or malformed proofs cost nothing — they are rejected in simulation for free and, if submitted, fail at base fee only.
  • Pre-validate before submitting. The live client (LiveSorobanClient::verify_proof_with_cost) already resolves the verdict by simulation, so the standard verification path pays exactly zero. Submission is only needed when an on-chain consumer must witness the proof, and only a proof that already simulated as valid should ever be submitted.
  • Budget with the oracle. verify_proof_with_cost returns the RPC’s minResourceFee and resource breakdown alongside the verdict, so callers can budget per-transaction cost without spending anything.

What we already do (and should not change)

  • Minimal calldata. The client sends the raw wire bytes — 288 B of public inputs, 14,592 B proof — with no envelope overhead. The SorobanPayload version/count prefix exists only for local round-tripping and never reaches the contract.
  • One op, no auth. A verification is a single InvokeHostFunction operation with an empty authorization vector.
  • No storage writes — the verifier never mutates state, so there is no write-footprint fee component.

What we cannot optimize (honest boundary)

The 95 M instructions are the fixed cost of any on-chain UltraHonk verification using this verifier: the contract is Nethermind’s audited implementation with an immutable VK and no admin path. Rewriting its cryptography (e.g. hand-optimizing the MSM or transcript) to chase gas would void the audit and the byte-for-byte agreement with bb, and is explicitly out of scope. The levers that could actually move this number are upstream:

  • A newer audited verifier tracking a Barretenberg version with a more efficient proof/verifier layout (the current one is pinned to the bb 0.87.0 byte format the contract was audited against).
  • Recursive/aggregated proofs (verify many transfers with one UltraHonk proof) — a proving-system change in the circuits, not a contract change.
  • Protocol-level host improvements (faster BN254 MSM/pairing in the Soroban host).

Re-benchmarking

The benchmark is a network-gated live test; it needs no funded account and pays nothing (simulation only):

CRUCIBLE_SOROBAN_LIVE=1 cargo test -p crucible-soroban-adapter --test live \
  -- --nocapture onchain_cost_report

Expected output:

on-chain cost report (testnet, transfer circuit):
  valid proof   : verified · minResourceFee 149208 stroops (0.014921 XLM) · 95297789 instructions · 0 B read · 0 B written · 2 read keys
  tampered proof: rejected (fee 0, 0 instructions before early exit)

The numbers above were captured 2026-09-08 on the Stellar public testnet (ledger ~4,569,705). They will move as the network’s fee parameters and host pricing evolve; re-run the benchmark before quoting them.

Noir toolchain integration

How crucible-prover uses the Noir toolchain: which binaries exist today, what the Rust adapters own, and which boundaries the circuit workspace is developed against.

Toolchain split (read this first)

Modern Noir (1.0.0-beta.x, the version this repo is developed against) split proving out of nargo:

StageToolProduces
Compilenargo compileACIR circuit artifacts (target/<pkg>.json)
Executenargo executea solved witness from Prover.toml inputs
Metricsnargo infoper-function ACIR/Brillig opcode counts
Unit testsnargo testin-circuit #[test] execution
Provebb prove (Barretenberg)the actual ZK proof
Verifybb verify (Barretenberg)proof acceptance against a VK

nargo prove / nargo verify no longer exist as subcommands. The Rust adapter split follows the tool split:

  • crucible-noir ends at artifacts + witnesses (compile, execute, info, artifact parsing, version checks). It never shells out to a prover.
  • Real UltraHonk proving belongs to the Barretenberg bb backend, executed through crucible-ultrahonk (BbToolchain + prove/verify in exec); see docs/ultrahonk.md for the validated nargo × bb pairing, the CLI surface the adapter drives, and the live test coverage.

Requiring bb is also why nargo test output — which runs the circuits on an in-process interpreter — is not a substitute for real proofs, only for witness-solvability checks.

Repository layout

The circuit workspace is independent of the Cargo workspace, mirroring the OpenZeppelin stellar-contracts model: circuits/ is its own nargo workspace and the Rust crates consume its outputs. See circuits/README.md for the package layout and commands.

Committed artifacts vs. build output

circuits/target/ holds compiled ACIR and solved witnesses; it is gitignored and rebuilt by CI. What is committed:

  • the Noir source (src/),
  • one synthetic valid test vector per operation circuit (<op>/testdata/Prover.toml), re-included in .gitignore deliberately — these are public fixtures with sample keys only.

Every other Prover.toml is gitignored: witness files can carry real private values and must never land in Git history. This is the same rule the crucible-witness crate enforces for Rust-side witnesses.

Version pinning

Every package declares compiler_version = ">=1.0.0" in its Nargo.toml and nargo enforces it. scripts/check-circuits.sh reports the toolchain version; CI installs via noirup and runs the circuit suites.

Nargo-driven integration tests

crates/noir has integration tests that run a live nargo compile / nargo execute against a scratch project to validate the adapter’s CLI surface, artifact parsing, and witness plumbing. Those tests are the only place in the Rust workspace that requires nargo on PATH; they are gated accordingly (see scripts/check-circuits.sh).

Performance

Where the costs are, how each is measured, and why the mock backend must never be used to reason about real proving speed.

Where the cost lives

The proving pipeline has four very different cost centers:

StageToolMeasured by
Constraint count / circuit sizenargo (compile-time)nargo info per package
Witness solvingnargo executelive runs, CI
UltraHonk provingbb provebenchmark --backend ultrahonk, live suites
UltraHonk verificationbb verifysame

Crucible’s own orchestration (dispatch, envelope assembly, validation, serialization) is a fourth, comparatively tiny cost — but it is the only one the mock backend measures, because the mock does no cryptography.

Measurement surfaces

  1. nargo info (constraint costs) — every operation circuit and every measurement gadget under circuits/gadgets/ reports its own ACIR/Brillig opcode counts. The gadget-per-primitive layout exists precisely so primitive-level cost can be tracked independently of whole-operation cost (docs/circuit-model.md).
  2. benches/ (in-process, toolchain-free) — mock round trip, witness build, and envelope serialization, best-of-N nanoseconds per operation (benches/README.md). These catch orchestration regressions anywhere, with zero cryptography.
  3. crucible-prover benchmark <op> — per-phase timings (prove, verify, serialize) plus proof/envelope byte sizes over --iterations. With --backend mock it prints an explicit TEST ONLY warning; with --backend ultrahonk it times real proving through bb.
  4. Live suitescargo test -p crucible-tests --test ultrahonk --test real_backend prove and verify real witnesses in CI; they are correctness gates first, but their wall time is a coarse regression signal.

Honest reporting rules

  • Mock numbers never imply crypto cost. The mock warning exists so a µs round trip is not mistaken for proving speed.
  • Proof size and verification cost are backend properties: envelope JSON length and proof bytes are reported by the CLI and benches; on-chain verification cost is a Soroban/calldata concern (docs/soroban-verification.md).
  • No stored baselines yet. These surfaces produce comparable numbers; wiring them into a baseline/comparison workflow (e.g. a performance issue template already exists under .github/ISSUE_TEMPLATE/) is the next step when real proving volume justifies it.

Privacy

The structural rules that keep private witness material out of logs, errors, fixtures, Git history, and CI output — by construction rather than by discipline.

What must stay private

Private witness material: secret keys, amounts, blindings, commitment openings — everything a circuit takes as a private parameter. The repository draws one hard line:

Values that must stay private are values that cannot be printed, logged, serialized, or embedded in an error — enforced in the type system, not in code review.

Mechanisms

MechanismWhere
SecretValue implements no Debug, Display, or Serializeinterfaces/src/circuit
ProofRequest (which carries secrets) has no Serialize; only a redacted JSON view existsinterfaces/src/proof_provider/request.rs
Debug on requests/witnesses prints names and counts, never valuesinterfaces, crucible-witness
The witness encoder is the single place private values leave memory; writes Prover.toml at 0600crates/witness/src/encoder.rs
Toolchain stderr is never echoed into errors (compiler diagnostics can embed source snippets)crucible-noir, crucible-ultrahonk
Prove/verify transcripts store redacted views onlycrates/prover-core/src/transcript.rs
Witness files are gitignored except the explicitly synthetic testdata/Prover.toml fixtures.gitignore

What is public by design

Not everything is secret. Proof envelopes are public — proof bytes, public outputs, and provenance carry no private values, which is exactly why they can be committed as fixtures (docs/proof-format.md). Public inputs (addresses, commitments, state roots) are visible in the Confidential Token model (docs/public-inputs.md).

Test coverage

The boundary is tested, not assumed:

  • SecretValue/request Debug and redacted JSON never contain secret values (tests/tests/security/witness_leakage.rs, unit tests in interfaces).
  • Prover errors, service transcripts, proof bytes, and response JSON never contain secret values.
  • A “leak scanner” test verifies the harness itself can detect planted secrets, so the tests cannot silently go blind.
  • Circuit-tier vectors only ever carry sample keys (0x1234), and the vectors crate routes them through the same SecretValue path as live material so the code path is identical without real secrecy.

Threat framing

See docs/security.md (guarantees and mechanisms) and docs/threat-model.md (adversaries and leak channels). The core privacy promise of Confidential Tokens — balances and amounts hidden, senders/recipients visible — is mirrored structurally here: private amounts cannot become public inputs, because the two sides are separate bag types that the circuits never confuse (docs/circuit-model.md).

Proof format

The wire representation of a proof: what is stored and exchanged, who owns the cryptographic encoding, and how the format stays versioned and traceable.

ProofEnvelope (format v1)

The unit of storage and exchange is the versioned ProofEnvelope (current version 1). It is self-describing — a consumer can decide how to verify and reproduce a proof without guessing at provenance:

ProofEnvelope {
    version            envelope wire-format version (reject > known)
    operation          register | deposit | merge | transfer | withdraw
    circuit            circuit id
    circuit_version    circuit the proof is valid for
    backend            mock | ultrahonk | …
    proof              ProofBlob { format tag, hex bytes }
    public_outputs     ordered bag of bound public values
    verification_key_id  which key the proof must be checked against
    artifact_checksum  SHA-256 of the artifact that produced the proof
    state_reference    (root, sequence) when the proof is state-bound
    metadata           request_id + producer label
}

The cryptographic encoding lives in the backend, never here. The envelope names the encoding via a format tag; it does not invent one. Current tags: mock-envelope-v1 (test-only deterministic envelopes) and ultrahonk-v1 (Barretenberg UltraHonk proofs).

Rules

  • Proof bytes are hex in the envelope (ProofBlob), for stable JSON serialization across languages and tools.
  • Parsing rejects unknown future versions (version > 1 fails with UnsupportedVersion): old tooling can never silently misinterpret newer proofs. Envelope JSON serialization is deterministic.
  • Backend identity and versions are carried, so a proof is traceable to its circuit, toolchain pairing, and artifact checksum (docs/compatibility.md, docs/reproducibility.md).
  • Every proof answers “which circuit produced this?” via the pinned artifact’s manifest (docs/artifacts.md), including the artifact checksum that also discriminates the verification-key id.

Committed material

Serialized v1 envelopes are committed under proofs/fixtures/ (one valid mock envelope per operation plus a tampered fixture) and judged by tests/tests/proof_fixtures.rs — a regression net that pins the v1 JSON layout and the verification contract against real bytes (proofs/README.md).

Proof Lifecycle

Every proof in Crucible passes through the same stages, from request to verified artifact. Knowing the lifecycle makes it possible to reason about where a failure can occur — and the tests in tests/ exercise every stage.

┌────────────┐   ┌──────────────┐   ┌───────────────┐   ┌──────────────┐
│  REQUEST   │──▶│   WITNESS    │──▶│   PROVING     │──▶│   ENVELOPE   │
│  validated │   │  assembled   │   │  (provider)   │   │   assembled  │
└────────────┘   └──────────────┘   └───────────────┘   └──────────────┘
                                                                 │
                                                                 ▼
┌──────────────┐   ┌──────────────────┐   ┌──────────────┐   ┌──────────┐
│  STORED AS   │◀──│  ROUND-TRIP      │◀──│  VERIFY      │◀──│  LOCAL   │
│  envelope    │   │  verification    │   │  (service)   │   │  verify  │
└──────────────┘   └──────────────────┘   └──────────────┘   └──────────┘

Stage 1 — Request

A ProofRequest is created (by the simulator, a scenario, or the CLI). It carries the operation, circuit, versions, backend, private witness, public inputs, and — for state-bound operations — a state reference.

Failure modes: missing witness, missing public inputs, missing state binding, unknown operation name, malformed field hex, duplicated witness names. All caught by structural validation before any backend is consulted.

Stage 2 — Witness

The private witness bag and the public input bag are assembled into a WitnessData. The assembler enforces that private and public names never overlap and that required names exist per operation.

Boundary: this is the only place private values exist as structured data. From here they go to the encoder (which writes Prover.toml with restrictive permissions for real circuits) — never to logs, errors, or serialization.

Stage 3 — Proving

The provider registered for the request’s backend runs. For the mock, this produces a deterministic envelope binding the request’s public context. For real circuits, this is where nargo/Barretenberg run — consuming the ACIR artifact and the solved witness.

Failure modes: unsupported circuit/version, unavailable artifact, artifact integrity failure, backend not installed, request targeting the wrong backend.

Stage 4 — Envelope assembly

The provider’s ProofResponse is wrapped into a ProofEnvelope: the versioned, self-describing container that answers which circuit, which version, which backend, which verification key, which artifact checksum produced the proof.

Stage 5 — Local verification (round-trip)

ProverService::prove_and_verify verifies the proof against its own response before handing it back. A proof that fails its own round-trip is surfaced as an error — callers cannot silently proceed with it.

Stage 6 — Verification service

crucible-verifier dispatches a VerificationRequest to every verifier registered for the proof’s backend. With multiple verifiers (local and on-chain), the VerificationReport states whether they agree — a disagreement is a first-class signal, not a swallowed anomaly.

Stage 7 — Storage

The envelope is the unit of storage, exchange, and cross-language fixture testing. It serializes to canonical JSON; parsing rejects future versions.

Failure taxonomy at verification

Verification distinguishes why a proof was rejected, so callers and tests can react precisely:

ReasonMeaning
InvalidProofproof bytes failed (tampered, corrupted, wrong key)
PublicOutputMismatchoutputs differ from what the proof commits to
StateReferenceMismatchbound to different state (stale/replay)
WrongVerificationKeyproduced under a different key
CircuitMismatch / VersionMismatchwrong circuit or version
ArtifactChecksumMismatchartifact was tampered or replaced
BackendMismatchformat does not match this verifier
MissingStateBindingbinding present on one side only

A rejected proof is a valid outcome, not an error: rejection of tampered, stale, or misattributed proofs is exactly the behavior Crucible exists to guarantee.

Proving Model

This document describes how a proof moves through crucible-prover: what a proof request is, how providers are selected, what a proof response commits to, and why every field on the wire exists.

Requests

A [ProofRequest] is the single input format across Crucible. It carries:

request_id        traceability across simulator, prover, scenarios
operation         register | deposit | merge | transfer | withdraw
circuit           the circuit that proves this operation
circuit_version   which version of the circuit (proofs are version-bound)
artifact_version  which compiled artifact generation is expected
backend           which proving system must serve this request
witness           PRIVATE material — never serialized or logged
public_inputs     public context the proof must bind to
state_reference   the state root + sequence the proof applies to

Validation is layered

  1. Structural (ProofRequest::validate): every operation requires a private witness and public inputs; state-bound operations (merge, transfer, withdraw) require a state reference.
  2. Circuit-level (prover-core::witness): the operation’s required private names must be present. The list is kept in lockstep with the circuits’ main parameters — e.g. transfer needs sender_sk, amount, old_amount, old_blinding, recipient_blinding, change_blinding. A request missing any required name could never produce a witness that satisfies the circuit, so it is rejected before dispatch.
  3. Provider-level: the provider registered for the request’s backend must declare support for the circuit at the requested version before any proving runs.

Nothing reaches a backend until all three layers pass.

The ProofProvider seam

ProofProvider (trait, in interfaces)
      │
      ├── MockProver        deterministic, TEST-ONLY
      ├── NoirProver        (arrives with circuits + bb)
      └── UltraHonkProver   (arrives with circuits + bb)

ProverService (in prover-core) is the client-facing facade. It holds a registry of providers, preflights each request, dispatches to the provider for the request’s backend, and wraps the result in a versioned ProofEnvelope.

Responses and binding

A [ProofResponse] is fully traceable: it names the circuit, circuit version, backend, verification key id, and artifact checksum the proof was produced against. Proofs are bound to their public context in two ways:

  • the proof bytes commit to the public outputs and state reference, and
  • verification checks the submitted context against the proof’s embedded context field by field.

If public inputs change after proof generation, verification fails — that is what prevents stale-state proof reuse.

The proof envelope

The ProofEnvelope is the storage/exchange form of a proof:

{
  "version": 1,
  "operation": "transfer",
  "circuit": "transfer",
  "circuit_version": "0.1.0",
  "backend": "mock",
  "proof": { "format": "mock-envelope-v1", "bytes": "..." },
  "public_outputs": { "entries": [["new_commitment", "c0ffee"]] },
  "verification_key_id": "mock-vk/transfer/0.1.0",
  "artifact_checksum": "<sha256>",
  "state_reference": { "root": "<sha256>", "sequence": 1 },
  "metadata": { "request_id": "...", "produced_by": "crucible-prover/0.1.0" }
}

Envelope parsing rejects future versions rather than guessing, so old tooling can never silently misinterpret newer proofs.

The mock backend (TEST ONLY)

The mock performs no cryptography. Its proofs are self-describing envelopes that bind every public-context field, so the full provider/verifier contract — validation, assembly, binding, rejection of tampered or misattributed proofs — runs in CI without paying proving costs. Key properties:

  • Deterministic: the same request always yields the same proof bytes.
  • Tamper-evident: flipping any byte breaks the keyed digest.
  • Diagnostic: because the envelope is in the clear, verification can say why a proof was rejected (wrong key vs. stale state vs. tampered bytes).

The mock is loudly labelled NOT CRYPTOGRAPHICALLY SECURE and must never be used where soundness is the point.

Real backends

Compilation of Noir circuits and witness solving are nargo’s job (crucible-noir); proof generation and verification are the Barretenberg backend’s job, executed through crucible-ultrahonk (see docs/ultrahonk.md). The compatibility matrix in crates/ultrahonk/src/backend.rs pins which (nargo, bb) pairs each circuit version was validated against — the single entry is validated by live proofs in the CI circuits job, and any future unvalidated pairing would be explicit, never assumed.

Public inputs

What a proof binds to, how the public side is named, and what happens when it drifts from the circuits.

Two roles, one value model

Public inputs (what the prover commits to when building a proof) and public outputs (what the circuit reports when it runs) are both ordered FieldValue bags over the same canonical-hex value model:

RoleTypeDirection
Inputs a request commits toPublicInputBagrequest → witness → circuit
Outputs the circuit reportsOutputBagcircuit → public outputs in the proof

Names never cross the private boundary: a value is either a public Field parameter (visible) or a private witness (never logged), enforced by the witness model (docs/witness-model.md).

The per-operation surface

The exact public surface of each circuit is a shared spec, not a Rust convention: interfaces::circuit::expectations pins, per operation, the ordered public parameter names and the public word count the compiled circuit must report. The real UltraHonk provider verifies the word count against the pinned surface after every proof and refuses to name words when the counts disagree — a circuit source that drifts from the expectations spec fails loudly instead of mislabeling outputs.

The current operation surfaces (see docs/circuit-model.md for full signatures):

  • register — public: account_address; returns nothing.
  • deposit — public: token_address, account_address, old_commitment; returns (new_commitment, nullifier).
  • merge — public: token + owner + two old commitments + root_hi, root_lo; returns (new_commitment, nullifier_a, nullifier_b).
  • transfer — public: token, sender/recipient addresses, old sender commitment, root_hi, root_lo; returns (recipient_commitment, change_commitment, nullifier).
  • withdraw — public: token, account address, commitment, root_hi, root_lo; returns (change_commitment, nullifier).

Addresses are public — that is the Confidential Token privacy shape, where who moves value is visible but how much is not.

State binding

State-bound operations (merge, transfer, withdraw) carry a StateReference whose 256-bit root is committed to as two 128-bit field halves (root_hi, root_lo) — a full root does not fit one BN254 field. The halves are folded into the circuit’s nullifier, so a proof cut against root A cannot be replayed against root B (docs/ultrahonk.md, docs/security.md). The split convention is shared by the fixtures, the witness path, and the verifier’s structural checks (StateReference::root_halves).

Binding and mismatch

A proof’s public outputs are part of its envelope, and verification checks that the submitted context matches the proof (see docs/verification.md). Changing any bound public output after the proof exists fails verification — pinned by tests/tests/invariants/public_inputs_bound.rs and the security suite’s wrong-context tests.

Reproducibility

What it means for a proof to be reproducible in this repository, and every pin that makes it so. Reproducibility is not just determinism of one artifact — it is a chain: same sources + same toolchains + same inputs ⇒ same bytecode, same manifest, same fixtures.

The pins

PinWhereEnforced by
Rust toolchain 1.98rust-toolchain.tomlrustup
nargo 1.0.0-beta.26TESTED_NARGO_VERSION in crates/noir/src/lib.rs, scripts/setup-noir.sh, CIversion gate in crucible-noir, CI install
bb 6.0.0-nightly.20260903TESTED_BB_VERSION in crates/ultrahonk/src/lib.rs, CIversion gate in crucible-ultrahonk, CI install
nargo × bb pairing + circuit versionsBACKEND_COMPAT in crates/ultrahonk/src/backend.rsprovider supports/check_supported
Canonical value encodingFieldValue/SecretValue canonical lowercase hex (no 0x, no leading zeros)constructors reject non-canonical forms
Envelope format v1 + deterministic JSONcrates/proof-typesserialization is field-ordered; parsing rejects future versions

Deterministic artifacts

Compiled bytecode is pinned with byte-for-byte reproducibility:

  1. Artifacts are generated by a deterministic path: per-package nargo execute (see scripts/generate-test-vectors.sh) — a whole-workspace nargo compile produces different debug-metadata ordering, so the generate path matters as much as the compiler version.
  2. crucible-prover artifacts generate writes bytecode + a canonical manifest.json (sorted file list, ordered JSON); identical bytecode reproduces byte-identical manifests.
  3. CI runs the fresh-compile determinism gate: generate to a fresh root and diff -r against the committed artifacts/circuits/ — a circuit change that forgets to re-pin, or a toolchain drift that changes bytecode, fails CI.
  4. The strict loader rejects any committed artifact that no longer matches its manifest (docs/artifacts.md).

Deterministic fixtures

  • Catalog vectors: expected public outputs are captured from real nargo execute runs, and the circuit tier re-checks them on every test run (docs/test-vectors.md).
  • Mock proofs are deterministic (same vector + same mock key ⇒ same bytes), so proofs/fixtures/ regenerate as a no-op diff unless the envelope format or a vector changed; scripts/generate-proof-fixtures.sh reproduces them exactly.

What is not claimed reproducible

Real UltraHonk proof bytes are produced by bb and are treated as opaque backend output; reproducibility guarantees apply to the context around them (artifact checksums, verification-key ids, public outputs, state binding), not to byte-identity of proofs across backend versions — which is precisely why backend version identity is pinned and carried on every envelope.

Regen workflow

MaterialCommand
Circuit vectorsbash scripts/generate-test-vectors.sh
Pinned artifactscargo run -q -p crucible-cli -- artifacts generate
Proof fixturesbash scripts/generate-proof-fixtures.sh

Each produces a no-op git diff when nothing changed — drift is loud by design.

Security

This repository handles the two things a proof system must never get wrong: private witness material and the integrity boundary between a proof and the artifact that produced it. This document states the guarantees, the mechanisms, and how they are tested.

See threat-model.md for the adversarial view, and SECURITY.md for how to report a vulnerability.

Guarantees

G1 — Private witness values never leak

Private values (SecretValue, PrivateWitnessBag, WitnessData) are structurally incapable of leaking by accident:

  • SecretValue implements no Debug, Display, or Serialize. It cannot be formatted, logged, or JSON-encoded; the only escape hatch is the explicit, consuming SecretValue::into_hex() used by the encoder.
  • ProofRequest carries secrets, so it has no Serialize. Its only JSON path is redacted(), which emits names and counts, never values.
  • Debug views are redacted by construction.
  • Toolchain (nargo) stderr is never echoed into errors, because compiler diagnostics can contain source snippets with witness values.
  • The witness encoder writes Prover.toml with restrictive permissions and its output is never logged.

Tested by: tests/security/witness_leakage.rs and unit tests in interfaces, witness, and mock asserting secrets never appear in debug output, errors, transcripts, or JSON.

G2 — Tampered proofs are rejected

A proof is bytes; anyone can flip bytes. Crucible detects this at two levels:

  • the mock backend keys a digest over its envelope payload, so any byte flip breaks verification (InvalidProof);
  • real backends provide their own cryptographic soundness — the mock only exists to exercise the same rejection paths in CI.

Tested by: tests/proofs/tampered.rs, tests/verification/corrupted_proof.rs.

G3 — Proofs are context-bound

A proof valid for state A, outputs X, key K, circuit C must fail when any of those change:

  • different public outputs → PublicOutputMismatch
  • different state root → StateReferenceMismatch (stale-state / replay)
  • different verification key → WrongVerificationKey
  • different circuit or version → CircuitMismatch / VersionMismatch

Tested by: tests/verification/*, tests/security/*, and tests/invariants/*.

G4 — Artifacts are integrity-checked before use

crucible-artifacts refuses to load an artifact whose bytes do not match its manifest: missing files, extra files (strict mode), and single-bit flips all abort the load. Manifest paths are validated against path traversal, and a manifest’s own checksum can be pinned externally to detect manifest tampering (file hashes alone cannot, since an attacker who can replace files can replace the manifest).

The guarantee is in the proving path, not just in a library: the UltraHonk provider proves only from the pinned artifact root (artifacts/circuits/<op>/), strict-loading each artifact through this loader before any witness is solved or bb runs. artifacts check re-runs the same load from the CLI, and CI additionally diffs a fresh compile against the committed artifacts so a circuit change that forgets to re-pin fails the build instead of proving against stale bytecode.

Every checksum above is a single SHA-256, and each one is pinned to a known-answer vector computed independently of this code, so a change to the hash construction fails the build rather than silently changing what an artifact digest means. The rules that govern hash dependencies, including why sha2 is deliberately held at 0.10 instead of resolving two implementations of SHA-256 into one binary, are in dependency-policy.md.

Tested by: tests/security/artifact_tampering.rs, the artifact crate unit suite, and the live tests/tests/artifacts.rs (tampered bytecode, missing manifest/bytecode, and planted files against a copy of the pinned artifact — all rejected before any proving work).

G5 — Verification is not assumed equivalent across verifiers

Local verification and on-chain verification are different code paths. crucible-verifier runs a proof through every verifier registered for its backend and reports disagreement explicitly.

Tested by: tests/integration/* and the verifier crate unit suite.

Privacy rules for contributors

  1. Never add Debug, Display, or Serialize to a type that can hold private witness values.
  2. Never include a witness value in an error message, log line, panic, or test fixture. Error messages carry names and identifiers only.
  3. Never echo toolchain stderr verbatim into errors.
  4. Never commit Prover.toml, generated proofs, or generated artifacts with real secrets (.gitignore covers generated paths; committed fixtures live under test-vectors/ and contain no real secrecy).
  5. When in doubt, run the leakage tests: they scan debug output, errors, transcripts, and JSON for known secret values from the fixtures.

Testing philosophy

The mock backend makes security tests deterministic and fast, and because its envelopes are self-describing it can say exactly why a proof was rejected. That diagnostic power is a test double’s feature, not a real backend’s — security tests that assert specific VerificationFailure reasons encode the mock’s behavior and are complemented by the real-backend tests in tests/tests/ultrahonk.rs (tampered proofs, wrong verification keys, and changed public inputs must all fail real bb verification).

The repository forbids unsafe code workspace-wide (unsafe_code = "forbid"): witness and verification-key material is handled here, so the code must stay in safe Rust.

Simulator integration

How crucible-prover connects to crucible-simulator — the boundary, the seams, and the adapter crate that translates between the two repositories.

The boundary

The polyrepo split is strict:

crucible-simulator owns the state and execution model. crucible-prover owns proving.

This repository therefore contains no state engine: no balances, no ledger, no execution. It answers one question — can I construct and verify the proof for this state transition? — given the state a simulator provides. Duplicating the simulator’s state engine here is an explicit anti-goal (docs/architecture.md).

Seams

Everything the simulator touches is defined in the interface crate, not in any backend:

  • ProofProvider — the contract every backend (mock, ultrahonk) implements. The simulator never depends on a concrete prover.
  • Prover — the client-facing facade (ProverService) that validates, dispatches, and round-trip-verifies.
  • ProofRequest / ProofResponse — the wire shapes, versioned and privacy-correct: requests carry private witness bags (no Serialize, only a redacted view for simulator logs), responses are public and traceable (docs/proving-model.md).
  • StateReference — the (root, sequence) pair that lets the simulator name which state a proof applies to, and lets the verifier reject stale/replayed proofs structurally (docs/verification.md).

The adapter (adapters/simulator)

adapters/simulator implements the simulator’s proof seam over the real prover, without importing the simulator’s state engine. The simulator depends on this crate only through its own [ProofProvider] trait:

Simulator flow ──► ProofRequest (simulator) ──► ProofRequest (prover)
       ──► ProverService ──► ProofResponse ──► ProofReference

Its job is mapping — simulator account/commitment/operation types onto the circuit’s named public/private inputs (docs/public-inputs.md) and routing through the Prover trait with the backend chosen per test depth. The translation binds the real circuit ABI: every public parameter and private witness the circuit declares (see [crucible_interfaces::circuit::expectations]). Translation differs by backend:

  • Mock backend (ProverBackedProvider::mock): deterministic digest encodings of the request. The mock enforces structure, not cryptography — witnesses the request does not carry become reproducible placeholders.
  • Real UltraHonk backend (ProverBackedProvider::ultrahonk): the circuit’s exact values. Witnesses come from the simulator model’s witness material — the acting account’s secret scalar, the openings (value, blinding nonce) of spent commitments, and the fresh blindings for new commitments, all carried on the simulator’s ProofRequest. Public parameters the circuit constrains (addresses, spent commitments) are derived with the circuit’s own hashes through the circuit oracle (CircuitOracle): a tiny Noir package (circuits/hashlib) executed by the pinned nargo that returns key_hash(secret) and commit(amount, blinding) — exact by construction, never a reimplementation. Anything the model does not carry is rejected with a precise reason, never fabricated.
  • State binding is real on both paths: the transfer flow names the state root/sequence it transitions, and the adapter binds its root_hi / root_lo halves as the circuit requires.

Wiring it up

#![allow(unused)]
fn main() {
// Fast, deterministic, no toolchain: TEST-ONLY crypto on the real code path.
let mut provider = crucible_simulator_adapter::ProverBackedProvider::mock();
sim.execute_with_proof_provider(operation, auth, Some(&mut provider))?;

// Real UltraHonk proving: requires nargo + bb on PATH and the circuits
// workspace (see scripts/check-bb.sh).
let mut real = crucible_simulator_adapter::ProverBackedProvider::ultrahonk(
    circuits_root,           // the circuits workspace (contains hashlib + target bytecode)
    vk_store_dir,
)?;
sim.execute_with_proof_provider(operation, auth, Some(&mut real))?;
}

Circuit-shape rules the real backend enforces

The protocol circuits consume a fixed number of spent commitments per operation (transfer and deposit/withdraw: one; merge: two). The simulator’s transfer flow spends all of a sender’s active commitments, so a sender holding several must merge first — the real translation rejects a multi-commitment transfer with a precise reason rather than proving an unsatisfiable witness. The end-to-end real test funds the sender with a single deposit for exactly this reason.

Verification

The end-to-end integration test (adapters/simulator/tests/end_to_end.rs) drives a full lifecycle — register, deposit, transfer — against the pinned crucible-simulator revision. The transfer runs twice:

  1. through the mock backend — fast, toolchain-free coverage of the whole wiring; and
  2. through the real UltraHonk backend — the same simulator lifecycle produces a real proof with bb that the real verifier accepts. This is the polyrepo-level proof that the three repositories speak one contract: simulator state → witness material → real circuit ABI → real UltraHonk proof → verification. It is gated on nargo + bb on PATH (CI installs both in the noir circuits job) and each run pays for real proving.

Honesty contract

  • Mock backend by default. [ProverBackedProvider::mock] wires the prover’s deterministic MockProver/MockVerifier — TEST ONLY, not cryptographically secure. It exercises the real code path (real request/response/envelope types, real preflight validation, real verification round-trips) with honest mock crypto, which is exactly what a fast, deterministic simulator layer needs.
  • Real proving is real. [ProverBackedProvider::ultrahonk] proves with bb through crucible-ultrahonk — the same backend an on-chain verifier accepts. The simulator model carries the witness material (secrets, openings, blindings); where the model genuinely has no value (e.g. the change blinding of an exact-balance transfer, whose zero-value change commitment the ledger discards), a deterministic request-derived value is used and documented — never fabricated public context.
  • Reject, never fabricate. A real translation request missing material a circuit constrains fails with a precise reason before any proving work starts.
  • Field values are fields. The adapter encodes only values below the BN254 scalar field modulus; FieldValue rejects anything at or above it on construction (a “field” that does not fit the field cannot be fed to the circuits).
  • Determinism preserved. Proofs derive purely from the request and the deterministic oracle, so the same simulator operation always yields the same proof bytes and the same proof reference — the simulator’s determinism guarantee holds through the real prover crate.

Soroban verification

How UltraHonk proofs are verified through a Soroban verifier contract — and why local verification must never be assumed equivalent to on-chain verification without testing.

Why it matters

Crucible’s core promise is that local and on-chain verification are tested, not assumed, to agree (docs/verification.md). The Confidential Token stack verifies UltraHonk proofs on-chain; the failure classes this workstream exists to catch:

  1. Encoding mismatch — the public inputs must reach the verifier contract in exactly the byte order and width it expects. Getting this wrong is a silent “verification failed” on-chain even though local verification passed.
  2. Calldata/verifier drift — the deployed verifier’s layout is defined by the verifier contract, which is external to this repository until a target-network deployment is pinned. This repository now pins one (below).

How on-chain verification actually works

The on-chain verifier is a Soroban contract. It holds one immutable verification key (set at deployment, no admin or upgrade path) and runs the UltraHonk verification itself against Stellar’s BN254 host functions. The contract used is Nethermind’s audited rs-soroban-ultrahonk wrapper (MIT licensed): __constructor(vk_bytes) at deploy, verify_proof(public_inputs: Bytes, proof_bytes: Bytes) afterwards. UltraHonk proofs are constant-sized, and the contract enforces the exact proof length.

The deployed testnet verifier

Contract: CCS6Z3VVCKV4F5BCH7VXJLKKWMDROUWOTZYROJ4T26CM7R45SE4IFYI2

This id is not just prose: adapters/soroban/tests/live.rs deploys its live suite against it, and an offline test requires every contract id recorded under docs/ to match that one — so this page and the code cannot drift apart, and a redeployment that updates only one of them fails CI. on the Stellar public testnet (Test SDF Network ; September 2015), deployed with the transfer circuit’s verification key.

  • Deploy tx: 2f821d072a241410ad92d29d14eb71a03dc4726b02df35ce73e94a8621ea7e52
  • Verified-proof tx: 5ef50bffa8a89914a987c1d06b10fa096a31db72af61d861fd751cdaad653e79
  • Tampered-proof rejection (simulated): HostError: Error(Contract, #4) = VerificationFailed.

The on-chain proof-format pin (important)

The contract was audited against the UltraHonk byte layout of bb v0.87.0 (Keccak transcript, proof = 456 field words / 14,592 bytes, VK = 1,760 bytes). Barretenberg changed this layout between versions:

ToolchainProofVKVerifies on the contract?
bb 0.87.0 + nargo 1.0.0-beta.9456 words (14,592 B)1,760 B
bb 6.0.0-nightly.20260903 (project pin)458 words (14,656 B)3,680 B❌ (ProofParseError)
bb 6.0.0-nightly + --oracle_hash keccak262 words (8,384 B)1,888 B

So the on-chain path requires the on-chain toolchain pin: nargo 1.0.0-beta.9 + bb 0.87.0, invoked with bb prove --scheme ultra_honk --oracle_hash keccak. The transfer circuit compiles on that pin with two cosmetic fixes (ASCII-only comments, a u8 shift cast — the code base targets the newer compiler, so this is a pin for the on-chain artifacts, not a circuit rewrite). The committed fixture below was produced exactly this way and is verified by the deployed contract.

What has landed (adapters/soroban)

adapters/soroban owns everything this repository controls on the on-chain path:

  • SorobanPayload — the exact wire bytes a verifier contract receives for one proof: circuit, verification-key hash, proof bytes, and the public-input calldata (via CalldataEncoder, 32-byte big-endian field elements in ABI order, versioned). Deterministic and round-trip-safe.
  • VerifierContract — the single boundary between the prover and a verifier contract. Both the live client and the local double implement it.
  • LiveSorobanClient — the real network client. It builds the verify_proof(public_inputs, proof_bytes) invocation and submits it for simulation to a Soroban RPC endpoint. Simulation is the definitive verdict here: the call is read-only, so the RPC runs it and either returns the Ok(()) value (results[0].xdr = ScVal::Void) or a HostError carrying the contract’s VerificationFailed code. Nothing is submitted, no fee is paid, no secret is needed — one JSON-RPC call per verification.
  • SorobanVerifier — a [Verifier] implementation that encodes a VerificationRequest and routes it through the contract boundary, so crucible-verifier’s VerificationService can register it alongside the local verifier and report agreement.
  • LocalContractDouble (TEST-ONLY) — consumes the same wire payload a deployed contract would and runs the same cryptographic check (bb verify against the payload’s public-input words). This is what makes local/on-chain agreement testable offline.

On-chain fixtures

test-vectors/onchain/transfer/ holds the transfer circuit’s on-chain-format artifacts (bb 0.87.0 Keccak): proof (14,592 B), public_inputs (288 B = 9 words), vk (1,760 B), produced with the pinned toolchain and verified live by the deployed contract. The proof matches transfer-valid-001 from the vector catalog.

Agreement tests

adapters/soroban/tests/agreement.rs proves real vectors, then runs the same proof through the local bb verifier and through the Soroban payload path, asserting they agree — and that a tampered payload (a flipped public-input word, an unknown verification key) is rejected on the on-chain path while the pristine proof still verifies locally.

Live agreement tests

adapters/soroban/tests/live.rs runs the committed on-chain fixture against the deployed testnet contract:

CRUCIBLE_SOROBAN_LIVE=1 cargo test -p crucible-soroban-adapter --test live

It asserts the fixture verifies on-chain and that a tampered proof is rejected. Without CRUCIBLE_SOROBAN_LIVE the tests skip (they must never hit a network silently). The contract and caller account default to the testnet deployment above and can be overridden with CRUCIBLE_SOROBAN_CONTRACT and CRUCIBLE_SOROBAN_SOURCE.

Audit status

An internal findings-style audit of this seam (owned code, dependencies, and the live deployment) is recorded in docs/audit-report.md: no critical or high findings, 0 vulnerabilities across all 144 locked dependencies, and the live invariants re-verified on-chain (legit proof verifies, tampered proof rejected, on-chain VK byte-identical to the committed fixture).

What remains

  1. Mainnet deployment — the same contract deployed with a mainnet-funded account and an audited key-management story; out of scope for the testnet milestone.
  2. Circuit parity on the on-chain pin — only the transfer circuit has committed on-chain fixtures so far. Register/deposit/merge/withdraw follow the same recipe when on-chain verification is needed for them.
  3. Toolchain convergence — the project’s local backend (bb 6.0.0-nightly.20260903) and the on-chain pin (bb 0.87.0) currently emit different proof layouts. When the audited verifier tracks a newer Barretenberg, the pins converge; until then the fixture + live tests pin the on-chain format.

Test vectors

The test-vectors/ directory holds the cross-language vector catalog: one JSON document per scenario, encoding what a proving implementation must accept and what it must reject, in a format no Rust code is required to read. The same file drives the Rust runner, the Python schema checker, and any future non-Rust consumer (a JS/Soroban harness, the scenario suites, bb backend tests).

What a vector says

Each file matches schemas/test-vector.schema.json and carries:

  • operation / circuit / circuit_version — what is being proven and by which circuit version.
  • categoryvalid, or a reject category such as wrong-owner, insufficient-balance, invalid (opening mismatch), stale-state, malformed-proof, replay. The category is the semantic contract: a wrong-owner vector must fail because the ownership assertion fails, not for any other reason.
  • witness — the circuit inputs: public entries plus private values. Values are canonical lowercase hex (no 0x, no leading zeros), the same format FieldValue/SecretValue enforce. Private values are the circuit’s private main parameters (sample keys only — see Privacy below).
  • expected_public_outputs — the exact values the circuit reports when the witness solves, in circuit return order. Captured from real nargo execute runs, so a fixture that drifts from the circuits fails loudly.
  • state_reference — the state context the proof binds to (null for operations that are not state-bound).
  • expect_verificationtrue for valid, false for reject categories.

Directory layout

test-vectors/
├── register/  valid/  wrong-owner/
├── deposit/   valid/  invalid/
├── merge/     valid/  invalid/
├── transfer/  valid/  invalid/  insufficient-balance/  wrong-owner/
├── withdraw/  valid/  insufficient-balance/  wrong-owner/

The reject categories map onto the witness shape: invalid means a witness whose commitment opening does not match its public commitment (deposit, merge, transfer), wrong-owner means the public address is not derivable from the private secret (register, transfer, withdraw), and insufficient-balance means the operation overdraws the consumed commitment (transfer, withdraw). Proof-level categories — stale-state, replay, malformed-proof — cannot be expressed as a witness file (they need a proof cut against one context and submitted against another), so they are exercised cryptographically in tests/tests/real_backend.rs and the security suite instead of as JSON fixtures; the schema still admits them for consumers that drive proofs per category.

Directory names mirror the category field; file ids are globally unique (<op>-<category>-<n>).

How vectors are judged (the runner)

tests/tests/vectors.rs executes every catalog entry in two tiers:

  1. Mock tier (always runs): every vector must produce a structurally valid ProofRequest; valid vectors must round-trip through the mock stack (prove → verify). The mock is semantically blind, so reject categories are not judged here — only their well-formedness is pinned.
  2. Circuit tier (runs when nargo is on PATH): each vector’s witness is written as a Prover.toml and executed against the real Noir circuit package. valid vectors must solve and report exactly the fixture’s expected outputs; reject vectors must not solve.

The two-tier split is deliberate and honest: the mock proves a request is expressible, the circuit proves its witness is (or is not) satisfiable. A vector failing the wrong tier is a catalog bug.

Generating vectors

Vectors are generated against real circuit executions, not by hand:

  1. scripts/generate-test-vectors.sh runs each committed circuit Prover.toml and fails if any no longer solves (drift canary).
  2. The JSON expected_public_outputs in the catalog are copied from real nargo execute output (the Circuit output: line), and the Rust runner’s circuit tier re-checks them on every run.

Add a vector by: computing the witness against the circuit, executing it through nargo to capture outputs, and committing the JSON. The runner validates that you got both tiers right.

Schema conformance

scripts/check-schemas.py validates every file under test-vectors/ against test-vector.schema.json as part of scripts/check.sh, so JSON-level conformance is enforced without building Rust.

Privacy

Vector private values are synthetic sample material — the same keys (0x1234, 0x5678) that appear in the circuit sources and their committed testdata/Prover.toml files. They carry no real secrecy. Real witness material must never be committed as a vector: the JSON loader rejects non-canonical hex, and the witness encoder crate keeps live material out of files entirely.

Testnet

Design for an optional execution layer that runs the real Soroban verifier contract against proofs — and why it is deliberately kept out of ordinary tests and CI.

Status: two paths, at different stages — the design below is not the whole story.

  • Verification against the deployed testnet contract is implemented and passing. adapters/soroban/tests/live.rs submits the committed on-chain fixture to testnet over Soroban RPC for simulation, which is a real execution of the deployed contract: the pristine proof is accepted and a tampered one is rejected. It needs no key and pays no fee, and it is gated on CRUCIBLE_SOROBAN_LIVE=1 so it never reaches a network silently. The contract and both transactions are recorded in docs/deployment.md.
  • The submission layer this page designs is not implemented. Signing and submitting transactions — fees, sequence numbers, result polling against a live network — and the scenario layer’s live-network adapter are the work described below.

Purpose

Local verification proves a proof is cryptographically valid. Testnet execution proves the whole pipeline works against reality: calldata encoding accepted by a deployed verifier contract, transaction submission, and on-chain acceptance/rejection. It is the natural extension of the cross-verifier agreement idea (docs/verification.md, docs/soroban-verification.md) into a live environment.

Separation from ordinary tests

Like the toolchain gates, testnet execution should be an explicit, opted-in surface, never a default CI dependency:

  • Local dev and CI run mock and real-local suites only (scripts/check.sh, scripts/test-all.sh, the workspace test suite).
  • A testnet run requires (a) the Soroban adapter, (b) a funded/configured network endpoint, and (c) a deployed verifier contract — all external state that ordinary tests must not depend on.

The existing pattern to follow is scripts/check-bb.sh / scripts/check-circuits.sh: a gate script that exits non-zero in CI when the prerequisite is missing but prints a clear skip locally. A testnet gate would follow that same pattern, naming its required endpoint and verifier-contract state explicitly.

Developer-preview caveat

Stellar’s Confidential Tokens are a developer preview: the contracts and verifier remain under audit and are not intended for production use. Testnet results are therefore integration evidence for the architecture, not a production assurance — the same caveat that applies to the whole proving stack until the circuit scheme is aligned with the Confidential Token specification (docs/deployment.md, docs/circuit-model.md).

Workstream order

  1. Soroban verifier integration (adapter + agreement tests) — docs/soroban-verification.md.
  2. Simulator adapter, so testnet scenarios run real state transitions — docs/simulator-integration.md.
  3. Testnet configuration + submission + verification on top of both.

Threat Model

An adversarial description of what crucible-prover must resist. Each entry names the adversary, the attack, and the defense that already exists or is planned. The security test suite (tests/security/) maps one-to-one onto the rows below.

Assets

AssetSensitivityWhere it lives
Private witness valuesHIGH — the privacy boundary itselfin-memory only, Prover.toml during proving
Verification keysHIGH — forge proofs if leakedartifact store, verified by checksum
Proving keys / secret backend materialCRITICAL — forge proofs if leakedbackend infrastructure, never in this repo
Compiled circuit artifactsMEDIUM — must be authentic, not secretartifacts/, integrity-checked
ProofsLOW per-proof, but must be authentic & context-boundenvelopes, stored/exchanged
Public inputs / state rootsPUBLICeverywhere, by design

Adversaries

A1. The curious bystander (privacy)

Goal: learn a private balance, amount, or opening from observable data.

Attack surface: logs, error messages, debug output, CI artifacts, transcripts, panics, serialized requests, git history.

Defenses: structural non-leakability (no Debug/Display/Serialize on secrets); redacted ProofRequest JSON; redacted transcripts; nargo stderr never echoed; .gitignore excludes generated witness/proof files; leakage tests scan everything for fixture secret values.

Status: enforced today. Test: tests/security/witness_leakage.rs.

A2. The forger (proof soundness)

Goal: produce a proof that verifies for a state transition that did not happen, or for a context it does not commit to.

Attack: forge bytes, reuse a proof against different state/outputs/key.

Defenses: real backends provide cryptographic soundness (mock does not, by design — it is TEST ONLY); context binding is checked field-by-field at verification; prove_and_verify round-trips every proof it returns.

Status: enforced today — cryptographic soundness via the Barretenberg adapter (UltraHonkProvider/UltraHonkVerifier), with the mock exercising the same rejection paths in CI. Tests: tests/tests/real_backend.rs, tests/tests/ultrahonk.rs, tests/security/proof_malleability.rs.

A3. The replayer / stale-state submitter

Goal: submit a previously valid proof after the state it applies to has moved on.

Attack: capture a valid proof for state root A; submit it when the account state is at root B.

Defense: state-bound operations (merge, transfer, withdraw) require a StateReference; verification compares the submitted reference against the proof’s binding and rejects with StateReferenceMismatch / MissingStateBinding.

Status: enforced today. Tests: tests/security/replay.rs, tests/security/stale_state.rs, tests/proofs/replay.rs.

A4. The artifact swapper (integrity)

Goal: make the system prove or verify with a modified circuit, or leak a verification key by convincing the loader to read outside the artifact root.

Attack: replace artifact bytes and/or its manifest; plant an extra file; craft a manifest whose paths escape the artifact directory.

Defense: crucible-artifacts verifies every file against its manifest before any content is returned, rejects undeclared files in strict mode, and validates paths against traversal; manifests carry their own checksum pinnable outside the artifact directory. The UltraHonk provider proves only from a pinned artifact root (artifacts/circuits/<op>/): it strict-loads the artifact through that loader before a single byte is touched, so swapped or tampered bytecode fails with ArtifactIntegrity/ArtifactUnavailable before any witness is solved or bb runs. A circuits-source change that forgets to re-pin its artifact fails CI (fresh compile must match the committed bytes byte-for-byte).

Status: enforced today — pinned artifacts committed for all five ops, verified by crucible-prover artifacts check. Tests: tests/security/artifact_tampering.rs and the live tests/tests/artifacts.rs (byte-flip, missing manifest/bytecode, and planted-file attacks against a copy of the pinned artifact).

A5. The misattribute (circuit/key confusion)

Goal: pass off a proof for circuit A (or version 1.0) as a proof for circuit B (or version 2.0), or under a different verification key.

Defense: proofs bind circuit, circuit version, backend, verification key id, and artifact checksum; verification checks each field and rejects with the specific reason.

Status: enforced today. Tests: tests/security/wrong_context.rs, tests/security/key_mismatch.rs, tests/verification/wrong_key.rs, tests/verification/wrong_public_inputs.rs.

A6. The toolchain saboteur

Goal: make compilation or proving fail in a way that leaks witness values through diagnostics, or make the system silently use an incompatible toolchain.

Defense: crucible-noir never echoes nargo stderr into errors; the toolchain adapter checks the nargo version against the supported major before running; crucible-ultrahonk gates the bb major version and CI installs the exact (nargo, bb) pair pinned in the compatibility matrix (noirup -v 1.0.0-beta.26 + bbup -v 6.0.0-nightly.20260903) — the same versions that generated the committed pinned artifacts.

Status: enforced — nargo and bb toolchains are both gated, and the live proving suite in tests/tests/ultrahonk.rs exercises the validated pairing on every CI run.

A7. The environment attacker (unsafe code)

Goal: exploit memory unsafety in witness/key handling.

Defense: unsafe_code = "forbid" workspace-wide. All handling is safe Rust.

Status: enforced today (workspace lint).

Out of scope (deliberately)

  • Side-channel resistance of real proving hardware/software (backend concern, addressed with the Barretenberg adapter).
  • Consensus/chain security (the ledger’s concern, not the prover’s).
  • Production key management (a different system; this repo never holds proving keys).

UltraHonk backend: real proving with Barretenberg

How crucible-prover generates and verifies real UltraHonk proofs through the Barretenberg binary (bb), and what this repository has validated about that pairing.

Toolchain pairing (validated)

ComponentVersionRole
nargo1.0.0-beta.26compile circuits, solve witnesses
bb6.0.0-nightly.20260903UltraHonk prove / verify

This exact pairing is what the repository is validated against: the compatibility matrix in crates/ultrahonk/src/backend.rs (BACKEND_COMPAT), the TESTED_BB_VERSION pin in the crate root, and the CI circuits job (which installs bb via bbup at that version) all agree on it. Proofs are only reproducible when the backend version is known: an UltraHonk proof produced by one Barretenberg version may not verify with another.

Installing bb: bbup normally resolves the right bb for your nargo automatically, but its mapping (bb-versions.json) can lag new Noir releases. When that happens, pin explicitly:

curl -L https://raw.githubusercontent.com/AztecProtocol/aztec-packages/refs/heads/next/barretenberg/bbup/install | bash
bbup -v 6.0.0-nightly.20260903 --no-modify-path

scripts/check-bb.sh reports the installed version and repeats these instructions.

The tool split (why bb exists at all)

Modern Noir (1.0.0-beta.x) removed proving from nargo:

StageToolProduces
Compilenargo compileACIR bytecode JSON
Executenargo executesolved witness (.gz) from Prover.toml
Provebb proveUltraHonk proof + verification key
Verifybb verifyaccept/reject against the VK

nargo test output is an in-process interpreter run and is not a proof; only bb produces cryptographic evidence.

The bb CLI surface (what this adapter drives)

The validated bb exposes a small, stable command surface (no more prove_ultra_honk / write_vk_ultra_honk subcommands):

bb prove -b <bytecode.json> -w <witness.gz> -o <dir> --write_vk --output_format json
bb verify -p <dir>/proof.json -k <dir>/vk.json -i <dir>/public_inputs.json
  • The scheme for Noir ACIR is ultra_honk.
  • With --output_format json, every artifact is self-describing and carries scheme, bb_version, and the backend-native verification-key digest (vk_hash / hash):
    • proof.json{ proof: [field words…], vk_hash, bb_version, scheme }
    • public_inputs.json{ public_inputs: [field words…], bb_version, scheme }
    • vk.json{ vk: [field words…], hash, bb_version, scheme }
  • Field words are 0x-prefixed 32-byte big-endian hex. Public inputs are listed in circuit order: pub parameters first, then returned values.
  • bb verify exits 0 on acceptance and non-zero on rejection; a rejected proof prints e.g. UltraVerifier: verification failed at reduction step.

Repository mapping

  • crates/ultrahonk/src/toolchain.rsBbToolchain: locating bb (BB_BIN override), parsing bb --version, major-version floor (pre-2026 CLI generations are rejected).
  • crates/ultrahonk/src/exec.rsprove() / verify(): process execution, JSON artifact parsing, provenance validation (scheme must be ultra_honk, bb_version must be present, and the digest a proof embeds must equal the digest of the VK written alongside it). A proof that fails verification is an outcome, not an error.
  • crates/ultrahonk/src/store.rs — the filesystem verification-key store (VkStore): providers write the vk.json a proof was produced with and verifiers resolve it by id under uhk/<circuit>/<version>/<artifact-sha>.
  • crates/ultrahonk/src/provider.rsUltraHonkProvider implementing [ProofProvider]: request bags → Prover.toml (witness encoder, 0600, in a scratch package copy) → nargo executebb prove → a [ProofResponse] whose public outputs are named from the pinned circuit surface.
  • crates/ultrahonk/src/verifier.rsUltraHonkVerifier implementing [Verifier]: resolves the key by id, re-encodes the submitted public outputs, and lets bb verify decide; precise context rejections are detected before cryptography runs.
  • crates/ultrahonk/src/backend.rs — the compatibility matrix.
  • crates/ultrahonk/src/calldata.rs — public-input encoding for an on-chain verifier (candidate layout; calibration against a deployed Soroban verifier is deferred to the Soroban batch).

Witness and bytecode are referenced by path only on the exec layer; private values leave memory once, through the witness encoder into a 0600 scratch Prover.toml. Errors carry paths and exit codes, never values or raw stderr.

Live test coverage

tests/tests/ultrahonk.rs runs real cryptography end to end, gated on both nargo and bb being on PATH:

  • witnesses solved from the committed vector catalog against the real register / transfer circuit packages;
  • bb provebb verify round trips;
  • binding: a register proof’s single public input must equal the committed account address; a transfer proof exposes exactly nine public words — token, sender, recipient, old commitment, root_hi, root_lo, then the three returned values — checked word by word against the fixture;
  • rejection: tampered proofs, tampered/wrong verification keys, and proofs submitted against changed public inputs all fail verification.

These are the cryptographic counterparts of the wrong-context rejections the mock backend can only simulate, and they run in the CI circuits job where the validated toolchain pair is installed.

Trait-level wiring and state binding

UltraHonkProvider/UltraHonkVerifier implement the same [ProofProvider]/[Verifier] seams crucible-mock implements, so simulators and scenario runners swap backends without changing code. The VkStore is the resolution layer: a proof never carries key material — verifiers resolve it by the id stamped on the response.

For the state-bound operations (merge, transfer, withdraw) the state reference is now a cryptographic binding: the circuits fold the two halves of the state root into their public inputs and into every emitted nullifier (see docs/circuit-model.md). A proof cut for root A therefore embeds different public words than one cut for root B. UltraHonkVerifier enforces the binding at two layers:

  1. Structural — before bb runs, the submitted state reference must agree with the root_hi/root_lo words the proof committed to; a stale submission is rejected with StateReferenceMismatch, one stripped of its binding with MissingStateBinding.
  2. Cryptographic — if the submitter rewrites both the state reference and the root words to root B, bb rejects: the proof was cut for root A.

tests/tests/real_backend.rs exercises both layers, plus the honest counterpart: register proofs remain deliberately unbound (no state is consumed) and deposit carries only an envelope-level reference — each pinned by regression tests so the boundary cannot silently widen.

The trait-seam suite needs nargo + bb on PATH and compiled bytecode under circuits/target/ (CI compiles before running it).

What is deliberately not here yet

  • On-chain verification (Soroban UltraHonk verifier, calldata calibration, verifier_target selection) is the Soroban adapter’s job.
  • The circuits in this repository encode the shape of Confidential Token semantics; exact commitment layout and key derivation must be aligned with the real OpenZeppelin spec before any production use.

Verification

How proofs are checked, why local and on-chain verification must not be assumed equivalent, and the structural checks that run before (and alongside) cryptography.

The Verifier trait

A Verifier takes a VerificationRequest — built losslessly from a ProofResponse/envelope — and returns a structured VerificationOutcome (verified, or a specific VerificationFailure reason: invalid proof, wrong verification key, public-output mismatch, state-reference mismatch, …). Implementations:

  • MockVerifier — deterministic envelope checker for the test-only mock backend (no cryptography).
  • UltraHonkVerifier — runs the real bb verify over the submitted context, resolving the verification key from the VkStore by id.

VerificationService: cross-verifier agreement

Local verification and on-chain verification are not assumed equivalent without testing. crucible-verifier’s VerificationService registers several verifiers per backend (e.g. local and, later, soroban, both serving ultrahonk) and runs a proof through all of them, returning a VerificationReport that states whether they agreed. Disagreement is surfaced, never hidden (tests/tests/verification.rs, tests/src/ stack). Dispatch is keyed by backend identity, so a mock proof can never accidentally reach an UltraHonk verifier.

Mandatory round trip

ProverService::prove_and_verify refuses to return a proof that fails verification against its own response — a proof is not handed to a caller until the local check passes. The CLI’s prove and the examples inherit this rule.

Structural checks before crypto

Some failures are detected without running the backend, because they are binding violations, not cryptography failures:

  • State binding — a proof cut against state root A, submitted with a state reference of root B (or stripped entirely), is rejected (StateReferenceMismatch) by the verifier layer before/independent of backend work; the cryptographic nullifier binding is additionally exercised in the live suite.
  • Context binding — wrong circuit, version, backend, or altered public outputs each produce distinct, structured rejections (tests/tests/security/wrong_context.rs).

Failure taxonomy

The integration suites pin a mapping from tamper/attack to failure reason: byte flips, truncation, splicing, and appended bytes → InvalidProof; verification-key substitution → wrong-key rejection; public-output edits → context mismatch; state-root substitution → state mismatch (tests/tests/security/). Reasons are machine-readable so callers never parse prose.

Witness model

How a proof request’s inputs are split, assembled, validated, and encoded for the toolchain — and why the private/public boundary is structural, not a convention.

The boundary

Every operation circuit has two kinds of inputs:

PRIVATE WITNESS   values only the prover knows (secrets, amounts, blindings)
PUBLIC INPUT      context everyone can see (addresses, commitments, state root)

In code the split is enforced by two bag types that cannot be confused:

  • PrivateWitnessBag holds SecretValues. SecretValue implements no Debug, no Display, no Serialize — it cannot be formatted, logged, or serialized by accident. The bag exposes names and counts only.
  • PublicInputBag holds FieldValues (canonical hex), which are public by design and safe to log and fixture.

A request carries both sides plus the circuit identity it belongs to (interfaces::ProofRequest). Because the private bag cannot serialize, ProofRequest has no Serialize either; its only JSON path is the redacted view ([ProofRequest::redacted]).

Assembly

crucible-witness owns the seam between a request and the toolchain:

  • [builder] — WitnessAssembler merges public and private bags into one WitnessData, rejecting name overlap between the two sides and missing required names. Required names are caller-supplied because the exact circuit interface is defined per circuit (interfaces::circuit:: expectations pins the per-operation public surface).
  • [validation] — structural rules every witness must satisfy before it can be encoded.
  • [decoder] — parses circuit public outputs back into an OutputBag. It never reconstructs private values.

Encoding (the single escape hatch)

Private values leave memory in exactly one place: [encoder::write_prover_toml], which writes the Noir Prover.toml layout as 0x-prefixed hex with 0600 permissions (Unix). Two rules make this safe:

  1. Nothing else in the workspace prints a secret value: WitnessData and both bags implement Debug as redacted views, error messages carry paths and counts, and toolchain stderr is never echoed into errors (compiler diagnostics can embed source snippets).
  2. 0x-prefixing is not cosmetic: Noir’s witness parser treats a bare string as decimal, so an unprefixed ab fails to parse and a bare 1234 silently means decimal 1234. The encoder guarantees hex.

The assembly step is also exposed directly — crucible-prover witness build builds a witness from a test vector and either prints a redacted summary or writes a Prover.toml for hand-off to a toolchain.

Where this fits

ProofRequest ──► WitnessAssembler ──► WitnessData
                                        │ encoder (0600)
                                        ▼
                                   Prover.toml ──► nargo execute (witness solve)
                                        │
                                        ▼
                        bb prove (consumes witness + pinned bytecode)

crucible-vectors maps catalog JSON onto the same bags so fixtures exercise the identical code path as live proving. See docs/proving-model.md for the request/response model and docs/privacy.md for the guarantees that follow from this design.

Contributing to crucible-prover

Thanks for contributing to the Crucible proof engine.

Code of conduct

All contributors must follow the Code of Conduct.

Repository map

  • interfaces/ — the stable contracts (ProofProvider, Verifier, request/response types). This crate must stay dependency-light; every other crate builds on it.
  • crates/ — the Rust engine: witness management, proof types, artifact integrity, prover orchestration, backends (Noir, UltraHonk), verification.
  • adapters/ — bridges to external systems (simulator, Soroban, testnet).
  • circuits/ — the Noir workspace (shared library, production circuits, measurement gadgets). Noir is a separate toolchain from Cargo.
  • schemas/, test-vectors/ — cross-language fixtures.
  • cli/ — orchestration only. No proving logic lives here.
  • docs/ — architecture and design documents.

Development setup

Install the pinned toolchains:

# Rust (pinned in rust-toolchain.toml)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# Noir (see scripts/setup-noir.sh)
curl -L https://raw.githubusercontent.com/noir-lang/noirup/main/install | bash
export PATH="$HOME/.noirup/bin:$PATH"
noirup

Scoping your change

crucible-scenarios compiles this repository at a revision pinned in its own manifest. That means a change here cannot break it — it builds the pinned revision, not your branch — but a change to a published contract under interfaces/ blocks its next pin bump until it adapts. Scope that as two issues: the change here, and a separate follow-up that bumps the pin and adapts the consumer. Never one PR spanning repositories; it cannot be reviewed or reverted as a unit.

The pinned set is recorded in crucible-scenarios/docs/cross-repo-pinning.md.

Do not require the Noir toolchain unless the issue is about it

nargo and bb are heavy, version-gated dependencies, and the real backend refuses to run without them (BackendUnavailable). Everything else builds and tests with only Rust installed: cargo test --workspace passes without either toolchain present, which is the surface most work should stay on. If an issue does require them, say so explicitly in the issue so a contributor can judge the setup cost before applying.

Before opening a PR

  1. cargo fmt --all -- --check
  2. cargo clippy --workspace --all-targets -- -D warnings
  3. cargo test --workspace
  4. If you touched circuits: scripts/check-circuits.sh
  5. If you touched serialization or proof formats: extend the matching JSON schema in schemas/ and regenerate affected test vectors.

Tests that touch private witness material must assert the material never surfaces in Debug/Display output, errors, or logs (see the witness leakage suites under tests/).

Commit conventions

  • One logical improvement per commit; do not bundle unrelated changes.
  • Detailed commit messages explaining the what and the why.
  • Reference the security implications of your change in the message when the change touches witness handling, verification, or artifacts.

Where to start

See docs/architecture.md and the issue templates under .github/ISSUE_TEMPLATE/. Good first issues are tagged good first issue; circuit and test-vector work does not require deep Rust knowledge, while prover-core and witness work requires care with the privacy boundary.

Security Policy

crucible-prover is the proof engine of the Crucible Confidential Token test suite. It handles material that must never be exposed: private witness values, secret randomness, and secret openings.

Reporting a vulnerability

Do not open a public issue for a security vulnerability. Report it privately by opening a security advisory on GitHub, or by emailing the maintainers (see CONTRIBUTING.md).

Please include:

  • the affected crate/version and the exact operation that triggers the bug
  • a minimal reproduction (test vector, proof request, or code snippet)
  • your assessment of impact, especially whether any private witness value, secret, or verification key material can be leaked or forged

You should receive an acknowledgement within 5 business days.

What this project considers in scope

  • Leakage of private witness, secret randomness, or secret openings through logs, errors, serialization, CI output, or public fixtures
  • Acceptance of tampered, stale, replayed, or misattributed proofs
  • Verification-key or artifact integrity failures (checksum bypass)
  • Circuit/public-input binding violations
  • Unauthorized proof generation or verification bypasses

Out of scope

  • The underlying cryptographic soundness of the Noir circuits or the UltraHonk proving system itself. Defects in upstream provers (Noir/Barretenberg) must be reported to their respective projects.

Security stance

This repository is part of the Crucible test suite for a protocol that Stellar labels a developer preview and which remains under audit. The mock prover shipped in this workspace is explicitly not cryptographically secure and must never be used outside tests.

Contributor Covenant Code of Conduct

Our Pledge

We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.

We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.

Our Standards

Examples of behavior that contributes to a positive environment:

  • Demonstrating empathy and kindness toward other people
  • Being respectful of differing opinions, viewpoints, and experiences
  • Giving and gracefully accepting constructive feedback
  • Accepting responsibility and apologizing to those affected by our mistakes
  • Focusing on what is best not just for us as individuals, but for the overall community

Examples of unacceptable behavior:

  • The use of sexualized language or imagery, and sexual attention or advances
  • Trolling, insulting or derogatory comments, and personal or political attacks
  • Public or private harassment
  • Publishing others’ private information without explicit permission
  • Other conduct which could reasonably be considered inappropriate in a professional setting

Enforcement Responsibilities

Community leaders are responsible for clarifying and enforcing our standards and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.

Scope

This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces.

Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement. All complaints will be reviewed and investigated promptly and fairly.

Attribution

This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.

🔥 Crucible Scenarios

CI MSRV License Pitch video

STRESS-TEST — the scenario, conformance, adversarial-testing, regression, invariant, privacy, compatibility, and stress-testing layer of the Crucible hybrid system for Stellar/Soroban Confidential Tokens.

📖 Documentation site for all three repositories: https://crucible-docs-flame.vercel.app

▶ Watch the pitch — 4:52

Crucible product pitch video

Click the thumbnail to watch the full product pitch. It covers the problem, the three-layer architecture, how the conformance and adversarial harnesses challenge the simulator and the prover, and the limits this project states about itself.

Every frame is a live capture — the deployed documentation site, the public repositories, and the deployed Stellar testnet verifier contract. Nothing in it is a mock-up, and nothing in it is a slide about a roadmap.

Pitch preview

            CRUCIBLE
               │
   ┌───────────┼───────────┐
   ▼           ▼           ▼
 SIMULATE     PROVE     STRESS-TEST
crucible-   crucible-  crucible-
simulator    prover     scenarios
   │           │           │
   └───────────┼───────────┘
               ▼
    Confidential Token
        validation

Crucible is three source repositories and one documentation site:

RepositoryLayerResponsibility
crucible-simulatorSIMULATEdeterministic simulation and state execution
crucible-proverPROVEcircuits, witnesses, proving, verification, proof artifacts
crucible-scenariosSTRESS-TESTscenario orchestration, conformance, adversarial, invariant, privacy, regression, fuzzing, stress testing, reporting
crucible-docsREADthe rendered documentation for all three layers — a build of their markdown, not a source of it

The overall lifecycle is: CONSTRUCT → EXECUTE → PROVE → VERIFY → OBSERVE → ASSERT → STRESS-TEST → REPORT.

What this repository does

crucible-scenarios answers one question:

Given a known initial state, a defined Confidential Token workflow, a proof provider, and expected protocol behavior, does the complete system behave correctly under normal, invalid, adversarial, privacy-sensitive, state-sensitive, concurrent, and high-load conditions?

It never becomes another simulator, another prover, another wallet, another token implementation, or a generic testing framework. It consumes stable interfaces from crucible-simulator and crucible-prover through the adapter crates in crates/adapters/ and orchestrates them into scenarios. It does not own: token contract implementation, Confidential Token cryptography, proof/circuit implementation, wallet or production account management, or production transaction orchestration.

Current status

What exists today is a production-quality orchestration and validation layer validated by passing tests, clean fmt/clippy, six benches, and a working CLI. Both upstream repositories are now wired behind their adapter contracts at pinned revisions: the real crucible-simulator flow engine and the real crucible-prover service, together in one run via MockHarness::run_integration. The remaining substantive gap — re-expressing the registered conformance pack against the real engine’s observation vocabulary — is called out explicitly below. Nothing should be read as existing until it is listed under Implemented today.

Implemented today

  • Scenario domain model (scenario-core) — validated definitions, metadata, categories, tags, capabilities, actors, typed operations, expectations, assertions, classified observations, outcomes, and a classified failure model where expected rejection is distinct from failure.
  • Deterministic runner (scenario-runner) — lifecycle, isolation, timeout, cancellation, cleanup, hooks, and an explicit retry policy that never retries deterministic failures.
  • Registry (scenario-registry) — registration, discovery, and filtering by category, tag, capability, and id prefix.
  • Deterministic fixtures (fixtures) — a synthetic, embedded, versioned catalog; no type in it can hold a secret.
  • Assertion engine (assertions) — state, balance, ownership, commitment, proof-valid/invalid, event, authorization, and privacy checks with expected/actual diagnostics that never print private values.
  • Adapters (crates/adapters/simulator, crates/adapters/prover) — the simulator and prover surfaces behind scenario-core’s provider-neutral contracts. Both surfaces ship two implementations, kept apart so neither borrows the other’s credibility:
    • the simulator surface is wired to the real crucible-simulator flow engine (RealSimulator, compiled from the simulator repository at a pinned revision) and also ships the deterministic in-repo test double (InMemorySimulator) that fixture postures need;
    • the prover surface is wired to the real crucible-prover service machinery (RealProver) and also ships the fixture posture double (FixtureProver) that negative and adversarial scenarios need. Runs exercise orchestration, binding, and the real simulator and prover contracts — never cryptographic validity.
  • Flows (flows) — reusable register, deposit, merge, transfer, withdraw, and full-lifecycle workflows, each asserting against independently computed expected values (never the surface’s own answers).
  • Registered scenario packshappy-path (6 flows), negative (11), conformance (5, stated against the in-memory double), conformance-real (5, stated against and judged by the real crucible-simulator engine), adversarial (4), privacy (4), concurrency (4), regression (3), compatibility (3), and performance (3) — 48 scenarios across ten packs, all runnable through the CLI and gated per family in CI (Level-2 scenarios.yml plus dedicated conformance, adversarial, regression, performance, and security workflows).
  • Privacy pack (privacy) — success-path and failure-path privacy scenarios (CT-PRIV-001..004) asserting that confidential amounts and witness material never reach public observations or reports; plus definition-time enforcement: scenario-core rejects proof-generation steps whose public inputs name a confidential field.
  • Concurrency pack (concurrency) — sequential-composition scenarios (CT-CONC-001..004): no lost updates across same-account spends, no interference between independent accounts, merge/transfer commitment bookkeeping, and order-independent, side-effect-free proof pipelines.
  • Test vectors (test-vectors) — a deterministic, machine-readable corpus of 24 vectors (CT-VEC-001..024) covering register, deposit, merge, transfer, withdraw, proof verification, and cross-operation sequences, each naming its input state, expected result and classification, proof behavior, and state transition; validated structurally and for coherence.
  • Regression catalog (regression) — permanent regression cases (CT-REG-001..003) converted from real bugs found during development, each pairing the issue reference, affected component, fixed version, and a minimal scenario that pins the fixed behavior; never deleted.
  • Fuzzing (fuzz) — seeded, deterministic mutation fuzzing (SplitMix64 PRNG, no external dependency) over operation parameters, negative controls, proof references, and public inputs; every finding records its seed and iteration, shrinks to a minimal case, and converts into a permanent regression test.
  • Invariants (invariants) — seven cross-operation checks (balance conservation, ownership, commitment consistency, proof binding, public input binding, replay protection, privacy) recomputed independently from the scenario definition and fixtures.
  • Privacy by construction — private/sensitive observations are classified and redacted at serialization; no code path renders witness material; the privacy invariant scans every public observation for confidential field names; pack tests serialize whole outcomes and assert the confidential literals are absent from the JSON.
  • Compatibility pack (compatibility) — circuit-version match and mismatch plus artifact reproducibility (CT-COMP-001..003).
  • Performance pack (performance) — configurable high-volume correctness-at-scale scenarios (CT-PERF-001..003).
  • Phase metrics — every scenario outcome carries per-phase timings (setup/simulation/assert/invariants/…); reports decompose time instead of collapsing it (spec §67).
  • Reporting crate (reporting) — suite aggregates rendered as JSON, JUnit XML (CI xUnit ingestion), and Markdown with per-phase timing tables; all renderers consume redacted summaries only.
  • Declarative format (scenario-format) — versioned JSON scenario documents with strict envelope validation and the same semantic validator the builder uses, all gated before execution (spec §33, §68); canonical examples in examples/declarative/, schemas in schemas/.
  • Parallel executionrun --parallel / report --parallel run independent scenarios over worker harnesses with byte-identical results (spec §71); concurrency-category scenarios always stay serial.
  • Soroban adapter (adapters/soroban) — contract-surface vocabulary, operation↔call translation, and event interpretation, isolated and hermetic; not yet wired to a live deployment client.
  • Testnet adapter (adapters/testnet) — explicit opt-in configuration, polling, and execution surfaces; never required by ordinary CI (spec §30).
  • CLI (cli, binary crucible-scenarios) — list, run, validate, inspect, report (--format text|json|junit|markdown), vectors, and fuzz, plus --parallel and --json, with CI-usable exit codes.
  • Benches (benches/) — six stable-Rust benches (scenario execution, assertion evaluation, fixture loading, proof flow, concurrency, reporting) asserting correctness properties while measuring mock-harness cost.
  • Scripts (scripts/) — test-all.sh (full local CI mirror), per-family run scripts, validation and generation helpers.
  • Contributor surface — 11 workflows (Level-1/2 gates, conformance, adversarial, regression, performance, security, opt-in testnet, release), eight issue templates, and a PR template encoding the boundary checklist (DoD #31).

DoD status

A line-by-line audit against the spec’s §76 Definition of Done lives in docs/dod-audit.md. The prover-side items are closed by the real prover wiring; the remaining open item is the real simulator wiring below.

Planned (designed, not yet implemented)

  • Real simulator wiring (DoD 5–6, the substantive gap) — the simulator adapter is an in-repo test double; the prover adapter is wired to the real crucible-prover service (see docs/prover-integration.md). Wiring the real crucible-simulator ledger behind the simulator contract is what will make conformance claims fully meaningful, and it cannot be completed inside this repository alone.
  • Live Soroban execution — the adapter is ready; a deployment client is not wired.
  • Agent scenarios — deferred by design (see docs/agent-scenarios.md): no agent protocol exists in the underlying implementation, and the project does not invent protocol semantics.

Repository layout

crates/
  scenario-core       domain model, outcomes, phase timings   (implemented)
  scenario-runner     deterministic execution + invariants    (implemented)
  scenario-registry   registration/discovery/filtering        (implemented)
  fixtures            deterministic synthetic fixtures        (implemented)
  assertions          observation assertions                  (implemented)
  scenario-format     declarative documents + validation      (implemented)
  adapters/simulator  simulator surface (test double today)   (implemented)
  adapters/prover     prover/verifier surface (fixture double
                      + real crucible-prover service)          (implemented)
  adapters/soroban    Soroban contract-surface adapter        (implemented, client not wired)
  adapters/testnet    opt-in testnet adapter                  (implemented)
  flows               happy-path workflows + catalog          (implemented)
  negative            expected-rejection scenarios (11)       (implemented)
  adversarial         assumption-violation scenarios (4)      (implemented)
  conformance         conformance scenarios vs the double (5) (implemented)
  conformance-real    conformance scenarios vs the real engine (5) (implemented)
  privacy             privacy and report-hygiene (4)          (implemented)
  concurrency         sequential-composition scenarios (4)    (implemented)
  compatibility       version-compatibility scenarios (3)     (implemented)
  performance         high-volume correctness-at-scale (3)    (implemented)
  invariants          cross-operation invariant checks (7)    (implemented)
  test-vectors        deterministic conformance vectors (24)  (implemented)
  regression          permanent bug regressions (3)           (implemented)
  fuzz                seeded deterministic fuzzing            (implemented)
  reporting           JSON/JUnit/Markdown suite reports       (implemented)
cli/                  crucible-scenarios command surface      (implemented)
benches/              six correctness-with-timings benches    (implemented)
scripts/              gate, validation, generation scripts    (implemented)
schemas/              versioned JSON schemas                  (implemented)
examples/declarative/ canonical declarative documents         (implemented)
docs/                 architecture + per-suite documentation  (implemented)
.github/              layered CI + issue/PR surfaces          (implemented)

See docs/architecture.md for the detailed design.

Scenario lifecycle

Every scenario conceptually follows:

Scenario Definition → Initial State → Actor Setup → Token Setup
→ Operation Construction → Simulator Execution → Witness/Proof Request
→ Proof Generation → Proof Verification → State Transition → Event Capture
→ Assertion → Invariant Validation → Result Classification → Report

Quick start

Requires a stable Rust toolchain (see rust-toolchain.toml).

cargo build --workspace
cargo test  --workspace

Using the CLI

# Build once, then drive the catalog:
cargo build -p cli
cargo run -p cli --bin crucible-scenarios -- list
cargo run -p cli --bin crucible-scenarios -- list --category conformance
cargo run -p cli --bin crucible-scenarios -- validate
cargo run -p cli --bin crucible-scenarios -- run CT-NEG-002
cargo run -p cli --bin crucible-scenarios -- run --tag proof --json
cargo run -p cli --bin crucible-scenarios -- run --category happy-path --parallel
cargo run -p cli --bin crucible-scenarios -- inspect CT-CONF-002
cargo run -p cli --bin crucible-scenarios -- vectors --json
cargo run -p cli --bin crucible-scenarios -- fuzz --seed 42 --iterations 100
cargo run -p cli --bin crucible-scenarios -- report
cargo run -p cli --bin crucible-scenarios -- report --format junit
cargo run -p cli --bin crucible-scenarios -- report --parallel
# The full local CI mirror:
scripts/test-all.sh

run/report exit non-zero when anything actually failed, so they gate CI; expected-failure scenarios (negative/adversarial) pass when the system failed exactly as declared. Machine output (--json) is emitted over the domain types, whose serialization redacts private values.

How to think about the tests in this repository

The repository deliberately separates claims:

  • Unit tests exercise one crate’s own contract.
  • Mocked integration runs (the flows::MockHarness path) exercise the full orchestration stack against deterministic test doubles. They validate that the scenario layer behaves and that expected values are derived independently — never that the system under test is cryptographically correct.
  • Real simulator validation runs through flows::MockHarness::run_real_simulator, which drives the actual crucible-simulator flow engine behind the adapter contract.
  • Full real-stack validation runs through flows::MockHarness::run_integration, which drives the real simulator engine and the real crucible-prover service in a single run.
  • Real prover validation runs through flows::MockHarness::run_real against crucible-prover’s actual service (hermetic mock backend) behind the adapter contracts and is labeled as such.

All three real paths are labeled where they appear: the adapters report which repository they drive, and the run entry points are distinct from the mocked run. What is still future work is repointing the registered conformance pack at the real engine (the pack asserts on the double’s invented public-balance vocabulary), plus real UltraHonk/bb proving and real Soroban/testnet validation.

Mocked runs must never be presented as cryptographic or on-chain evidence.

Relationship to the other Crucible repositories

  • crates/adapters/simulator and crates/adapters/prover define the provider-neutral surfaces through which scenarios consume crucible-simulator and crucible-prover, and both now reach the real repositories behind those contracts at pinned revisions. The in-repo doubles remain for fixture postures a real engine does not model. What cannot happen inside this repository alone is re-expressing the registered packs against the real engine’s observation vocabulary, since that vocabulary is defined by the simulator repository.
  • crates/adapters/soroban (contract surface, translation, events) and crates/adapters/testnet (explicit opt-in) exist and are hermetic; live-contract and network execution stay isolated and opt-in.

Documentation

The documentation in this repository is rendered together with crucible-simulator and crucible-prover at https://crucible-docs-flame.vercel.app. The markdown files here are the source; the site is a build of them. Rebuilds are nightly and on demand — a push here does not itself trigger one — so a change appears on the site within a day, or immediately if the documentation-site workflow is dispatched. To change a published page, change the file that owns it in this repository.

docs/error-codes.md lists every failure code the harness can raise and what it means. That table is a public interface: outcome classification, the JUnit and Markdown renderers, and scenario assertions branch on these codes rather than on message text, so the document is what lets a consumer interpret a report without reading this source. It is kept honest by scripts/check-error-codes.py, which runs in CI and fails when the document and the code disagree in either direction. It also records how these UPPER_SNAKE_CASE harness codes relate to the snake_case domain codes that pass through from crucible-simulator.

Contributing

Scenarios must validate externally observable behavior, derive expected values from declared inputs and protocol rules (never from the same internal function under test), and must not leak private witnesses. See CONTRIBUTING.md and docs/.

License

Licensed under either of

at your option, matching crucible-simulator and crucible-prover.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this repository by you, as defined in the Apache License, shall be dual licensed as above, without any additional terms or conditions.

Adversarial testing

Adversarial scenarios (crates/adversarial, pack adversarial) actively mutate a valid setup and require the mutation to fail the way the protocol says it must. They exist to catch unexpected acceptance: a mutated proof, witness, public input, or state that the system still accepts.

What is covered (CT-ADV-001..004)

  • Proof tampering — take a valid proof reference and corrupt it; a mutated/unknown proof must fail verification (CT-ADV-001).
  • Witness/public-input mutation — regenerate and re-verify while the statement changes; a proof whose public inputs no longer match must fail binding, and permuting the same public inputs must not change the verdict (binding is order-independent) (CT-ADV-002).
  • Replay / stale proof — a previously consumed proof or state must be refused on reuse (CT-ADV-003).
  • Public-input binding — changing a bound public input after proof generation invalidates verification (CT-ADV-004).

Failure classification

Every mutation must produce a predictable failure: tampered-proof, malformed-proof, input-binding-mismatch, state-binding-mismatch, wrong-circuit, unknown-proof. The packs assert the specific classification, so a mutation that fails for the wrong reason is caught.

Never invented semantics

Mutations only touch what the underlying model actually carries (proof references with statement/state fingerprints, public inputs, declared circuit versions). Replay behavior is asserted only to the extent the double defines it — no nonce/nullifier machinery is invented. Where the real crucible-prover replaces the double, these scenarios bind the same contracts against real verification.

Privacy

Mutation inputs are synthesized inside the scenario; adversarial runs never print or log witness material.

Agent scenarios (deferred by design)

The spec’s agent dimension (§32, §60) covers an agent-gated workflow — authorized/unauthorized/expired/altered-state/replay — where an agent authorization controls access to confidential operations.

Status: not implemented, deliberately

There is no agent protocol in the underlying Confidential Token model this repository validates. Per the repository’s governing rule — do not invent protocol semantics that do not exist in the underlying implementation (spec §33’s boundary, and §44’s “no invented security”) — implementing agent scenarios would require inventing an authorization scheme the system does not define, then claiming to test it. That is exactly the fake security the project forbids.

What exists that an agent feature would build on

When the underlying implementation defines an agent/authorization contract, the pieces are already in place:

  • Authorization failures are a first-class classified outcome (AUTHORIZATION_FAILURE) exercised by the negative pack (wrong owner, unauthorized actor, frozen account).
  • The frozen-account / policies fixtures define authorization postures.
  • The regression system can pin any agent-authorization bug as a permanent CT-REG-* scenario.
  • The issue templates provide an “agent” filing surface via the adversarial/security templates.

When the real protocol adds agents, this doc flips from “deferred” to “implemented” with the pack registered under the standard category/tag machinery — no rework of the runner or reporting layers needed.

Architecture

crucible-scenarios is the STRESS-TEST polyrepo of the Crucible hybrid system: a scenario orchestration and validation layer for the Crucible Confidential Token stack. It never defines the system — it tests it.

The three polyrepos

PolyrepoLayerOwns
crucible-simulatorSIMULATEdeterministic simulation and state execution
crucible-proverPROVEcircuits, witnesses, proving, verification, proof artifacts, proof interfaces
crucible-scenariosSTRESS-TESTscenario definitions/execution, orchestration, fixtures, test vectors, assertions, invariants, failure classification, negative/adversarial/privacy/conformance/regression/compatibility suites, stress/performance/fuzzing orchestration, reports, reproducible replay

What belongs here — and what does not

This repository owns: scenario definitions and execution; test orchestration; deterministic fixtures and versioned test vectors; expected outcomes and reusable assertions; cross-operation invariants; failure classification; regression cases; negative, adversarial, and privacy testing; compatibility and conformance checking; concurrency, stress, performance, and fuzzing orchestration; structured reporting; reproducibility and deterministic replay; integration-test coordination.

It does NOT own: the token contract implementation; Confidential Token cryptographic implementation; proof/circuit generation; wallet or production account management; compliance/sanctions policy; audit dashboards; production transaction orchestration; or any generic blockchain framework functionality. It must never re-implement simulator or prover semantics, never depend on their private internals, never modify production contracts from tests, and never treat mocked proofs as cryptographically valid.

Data flow

 Scenario
    │  (definition: metadata, actors, operations, expectations)
    ▼
 ScenarioContext ──── ScenarioRunner ──── SimulatorService (simulator adapter)
    │                                         │
    │      ProofProviderService (prover       │  operation execution
    │      adapter)  ── witness ──► proof     ▼
    │      VerifierService                    state transition + events
    ▼                                         │
 Observations (classified public/private) ◄───┘
    │
    ▼
 Assertions ──► Invariants ──► ScenarioOutcome ──► Reports (text/JSON/JUnit/Markdown)

The target full integration (real simulator wiring is planned; the real prover service is wired today, see Integration status):

crucible-scenarios
   ├── simulator  (adapter over crucible-simulator interfaces; double today)
   ├── prover     (adapter over crucible-prover; real service today)
   └── soroban    (contract-surface adapter; translation + events, client not wired)
              │
              ▼
      Confidential Token
              │
              ▼
         verification

Crate map and dependency direction

 scenario-core       domain model (definitions, outcomes, classification, phase timings)
 scenario-runner     executes scenarios (lifecycle, invariants, retry, cleanup, hooks)
 scenario-registry   discovers/filters scenarios
 fixtures            deterministic synthetic test data
 assertions          reusable validation over observations
 scenario-format     versioned declarative scenario documents + pre-execution validation
 adapters/simulator  simulator surface; today a labeled in-repo double
 adapters/prover     prover/verifier surface; fixture double + real adapter
                     (crucible-prover service, hermetic mock backend)
 adapters/soroban    Soroban contract-surface adapter (isolated; client not wired)
 adapters/testnet    opt-in testnet adapter (hermetic; network never required)
 flows               reusable protocol workflows + the happy-path catalog
 negative            expected-rejection scenarios            (CT-NEG-*)
 adversarial         assumption-violation scenarios          (CT-ADV-*)
 conformance         protocol-conformance suites             (CT-CONF-*)
 privacy             privacy and report-hygiene scenarios    (CT-PRIV-*)
 concurrency         sequential-composition scenarios        (CT-CONC-*)
 compatibility       version-compatibility scenarios         (CT-COMP-*)
 performance         high-volume correctness-at-scale        (CT-PERF-*)
 invariants          cross-operation properties (7 built-in checks)
 test-vectors        deterministic conformance vector corpus (CT-VEC-*)
 regression          permanent bug regressions               (CT-REG-*)
 fuzz                seeded deterministic fuzzing + finding reduction
 reporting           suite reports: JSON / JUnit XML / Markdown, per-phase timings
 cli                 crucible-scenarios command surface (10 commands)

Ten scenario packs (48 registered scenarios) and one declarative document corpus sit above the harness, all runnable through the CLI. Two of those packs are the same category judged by different systems: conformance asserts the in-memory double’s vocabulary, conformance-real asserts the real crucible-simulator engine’s, and the CLI routes each to the harness its contracts require rather than to one harness for both.

Integration status

The simulator adapter crate defines the provider-neutral contract scenarios consume, but today it is a deterministic in-repo test double: an in-memory ledger with fixture-derived posture. It exists so the orchestration layer can be built and validated hermetically before the real crucible-simulator repository is wired behind the same contract — which is planned and is what will make conformance claims meaningful.

The prover adapter crate implements the prover/verifier contracts two ways: a fixture-driven [FixtureProver] double (statement and state binding over proof-posture fixtures, needed by negative and adversarial scenarios that require a prover able to emit an invalid/tampered proof on demand) and a [RealProver] adapter over the actual crucible-prover service machinery — request preflight against the circuit ABI, provider dispatch, versioned proof envelopes, and a mandatory local verification round-trip — compiled from the crucible-prover repository via a pinned revision and run hermetically in CI on crucible-prover’s deterministic mock backend. Runs over the fixture double exercise orchestration and binding semantics; runs over the real adapter exercise the genuine prover service contract. Neither is cryptographic or on-chain evidence: real UltraHonk/bb proving is a heavyweight opt-in in crucible-prover itself and is exercised there in dedicated CI.

The Soroban adapter exists and is isolated: it defines the contract surface, operation→call translation, and event interpretation, unit-tested hermetically, but is not yet wired to a live deployment client. The testnet adapter exists and is explicitly opt-in: hermetic configuration, rolling, and execution surfaces; ordinary CI never touches a network (spec §30). Scenarios that cannot run honestly in the current environment skip deliberately rather than mis-execute.

Dependency direction stays approximately: scenario-core → runner → scenario implementations, with adapters consuming core’s stable interfaces and reporting consuming results. No crate depends on private internals of another Crucible repository.

Testing model and oracles

Every scenario conceptually follows: definition → initial state → actor setup → token setup → operation construction → simulator execution → witness/proof request → proof generation → proof verification → state transition → event capture → assertion → invariant validation → result classification → report.

Expected results are derived from declared inputs and protocol rules, never by calling the same internal state-transition function under test. Layered oracles are used:

  1. explicit expected result,
  2. protocol invariant,
  3. independent state comparison,
  4. cross-component comparison.

A single successful return value is never sufficient evidence for an important scenario.

See also

Assertions

Assertions are the scenario’s declarative checks over a run’s recorded observations. They live in scenario-core (the spec vocabulary) and are evaluated by the assertions crate’s deterministic AssertionEngine.

Scope

The engine evaluates only what the run recorded — public observations, the event log, and the terminal status. It never reaches into adapter or fixture internals, so assertions behave identically across the mock harness, a real simulator, and a Soroban environment.

Kinds

  • Success / failure — the run’s terminal status matched.
  • Balancebalance.<actor>.<token> equals an independently expected public value.
  • Ownershipownership.<token> belongs to the expected owner.
  • Authorization — an actor’s action was authorized as declared.
  • Proof valid / invalid — a proof reference verified (or was refused).
  • Commitment — a commitment reference equals its expected digest.
  • Event / no event — a public event code was (or was not) emitted.
  • Private not visible — a confidential field never appears in any public observation or report surface.
  • State binding — an operation is bound to the referenced state.
  • Replay rejected, serialization equal, version compatible — used by the adversarial, compatibility, and regression packs.

Privacy guarantee

Assertion diagnostics carry expected/actual values as strings that never contain private material: the messages module builds redacted, human-readable text. Assertions over confidential fields use the private_not_visible kind, which passes only when the field is absent from every public surface — the reverse of a leak check.

Failure semantics

Each failed assertion records its severity and a diagnostic. A critical assertion failure classifies the run as failed; the outcome carries the assertion results for the report. Expected-failure scenarios (negative, adversarial) declare the failure they expect, so a correct rejection is a passing run, not an error.

Compatibility

Compatibility scenarios (crates/compatibility, pack compatibility) pin the version-compatibility contracts of the proof pipeline:

  • CT-COMP-001 — a proof generated against a circuit version verifies against the same version (the compatibility baseline).
  • CT-COMP-002 — the same proof is refused and classified wrong-circuit when the request pins a different circuit version.
  • CT-COMP-003 — identical statements produce deterministic, reproducible proof artifacts across independent submissions (same statement, distinct envelopes), so artifact stability is checked rather than assumed.

Versioning surface

Circuit and protocol versions are first-class scenario metadata (metadata.circuit_version, metadata.protocol_version). Scenarios record the version they target and the verdict they expect from that version — never from the verifier’s own answer — so a compatibility check stays an independent oracle (spec §48, §29).

Scope and honesty

Compatibility today is judged against the fixture prover double’s deterministic version binding. It proves the scenario layer enforces version semantics. When crucible-prover is wired behind the same adapter, these scenarios become genuine cross-version checks (e.g. proof generated by one prover version verifying under another) without rework.

Concurrency testing

Concurrency is modelled at the scenario/execution level. The repository’s simulator is deliberately single-threaded and deterministic — pretending it is concurrent would defeat reproducibility — so operations that would race in a live system are composed sequentially here, and the scenarios pin the ordering semantics the ledger must honor. A concurrency regression in the real system (a lost update, an interference bug, a commitment inconsistency, a replay slip) shows up as a failed sequential composition.

The properties under test

  1. No lost updates — two spends from the same account compose: the second spend must see the ledger after the first.
  2. No interference — transfers on different accounts must not clobber each other’s state; each moves exactly its declared amount.
  3. Commitment bookkeeping — interleaved operations (merge then transfer) keep commitment statuses reachable from the fixture posture.
  4. Proof pipeline independence — proof generation and verification are order-independent (each proof binds its own statement) and side-effect-free (verification never touches the ledger).

Scenarios

The concurrency pack (CT-CONC-001..004):

IdWhat it pins
CT-CONC-001alice spends 200 to bob then the remaining 100 to issuer; no state is lost between the two spends
CT-CONC-002alice→bob and bob→issuer compose without interference
CT-CONC-003a merge followed by a transfer keeps commitment bookkeeping consistent
CT-CONC-004two proofs are generated and verified before either proved transfer executes; both then compose

Every scenario is judged with the built-in invariant registry attached (ownership, commitment-consistency, replay-protection, privacy; the proof pipeline also holds proof-binding and input-binding), and the pack’s tests require every run to pass with zero invariants failed.

Run them with:

cargo run -p cli --bin crucible-scenarios -- run --category concurrency
cargo run -p cli --bin crucible-scenarios -- report

What is and is not covered

  • Covered: sequential-composition correctness over the fixture posture, and the ordering semantics listed above.
  • Not covered: true multi-threaded execution, live-race detection, or contention timing — those require the real simulator or a Soroban environment and are future work behind the adapter contracts.

Conformance

Conformance behavior is pinned by two packs in crates/conformance, because the same category of question has to be asked of two different systems and the answer means something different in each case.

PackIDsJudged byAsserts
conformanceCT-CONF-001..005the in-memory double + fixture proverthe double’s vocabulary: public balances, fixture commitment ids, ct_* event codes
conformance-realCT-CONF-R01..R05the real crucible-simulator flow engineonly facts the engine publishes: op.<id>.accepted, its own event codes, its public state counts

Which harness judges which pack

A contract’s tag decides, never a heuristic. Every conformance-real scenario carries the real-engine tag, MockHarness::run_real_simulator judges it, and the CLI enforces the pairing:

crucible-scenarios run --tag real-engine --real-engine   # the engine judges these
crucible-scenarios run --category conformance            # skips them, with a note
crucible-scenarios report                                # runs each pack on its own harness

run refuses to judge a double-backed contract with the real engine (that contract’s vocabulary does not exist there, so a “failure” would say nothing about the engine) and refuses a real-engine-only selection without the flag. A test asserts the packs are not interchangeable: the real-engine contracts are not all satisfiable by the double, so the distinction is real rather than nominal.

Honest scope

Simulate: real. The conformance-real pack is judged by the actual crucible-simulator flow engine, compiled from the simulator repository at a pinned revision. A pass there is evidence about that engine.

Prove: mock. Both packs run against crucible-prover’s hermetic mock backend. Nothing here is yet a cryptographic conformance check; real UltraHonk/bb proving is heavyweight and opt-in in the prover repository. That is the remaining gap, and it is gated on the same external audit the prover repository needs — not on work in this one.

Anti-circular oracle design

The pack avoids the “simulator says X → ask the simulator → confirm X” trap. Expected outcomes are derived independently:

  • balances are computed from the declared deposit/transfer/withdraw amounts against the fixture starting ledger, by plain arithmetic in the scenario,
  • ownership is derived from the transfer’s sender/recipient,
  • proof validity expectations come from the pinned circuit version and the statement, not from the verifier’s own verdict,
  • every scenario also runs with all seven built-in invariants attached, so the pack judges global properties (conservation, ownership, commitment consistency) across whole runs, not just per-step effects.

What is covered (CT-CONF-001..005)

  • Register, deposit, merge, transfer, withdraw as independent conformance checks, each asserting events, balance deltas, ownership, and state effects.
  • A full-lifecycle conformance run (register → fund → merge → prove-and-transfer → withdraw) judged with every invariant attached.

What the engine pack covers (CT-CONF-R01..R05)

The expected counts are derived from the operation sequence alone, so they are an independent oracle rather than a restatement of the engine’s answers:

IDContractExpected from the sequence
R01a 40-unit depositexactly 1 live commitment, 2 accounts, 1 token, 2 transactions
R02two deposits then a mergethe two commitments consolidate to exactly 1
R03a funded 30-unit confidential transferthe spent commitment is consumed and two are created (live: 1 → 2), and no per-holder public balance is published
R04a 30-unit withdrawalcompletes under the engine’s withdrawal_completed event
R05the full lifecycleall five lifecycle event codes across seven steps, no rejection

Boundary

Conformance scenarios implement no state or proof logic. They compose the adapter services and assert on observed effects against independently computed expectations.

Contributing

This repository is the STRESS-TEST layer of Crucible. It orchestrates crucible-simulator (SIMULATE) and crucible-prover (PROVE) — it never re-implements them. The governing boundaries (spec §3, §44):

  1. No second state engine. Balance math, commitment transitions, and token accounting belong in crucible-simulator or its adapter — never in a scenario, pack, or flow.
  2. No second proof engine. Witness construction, proving, and verification belong in crucible-prover or its adapter.
  3. No invented protocol semantics. Assert only guarantees the underlying implementation defines. Mocked proofs stay labeled mocked.
  4. Independent oracles. Expected outcomes come from declared inputs, fixture starting state, and protocol rules — never from asking the surface under test what happened (anti-circular testing, spec §29).
  5. No private data. Witnesses, keys, seeds, and confidential amounts never reach logs, reports, observations, or fixtures.

Starting points

  • Read docs/architecture.md and the crate-level docs.
  • File an issue from the templates (.github/ISSUE_TEMPLATE/) — every template encodes the boundary checklist.
  • One PR = one self-contained improvement, with a CHANGELOG.md entry when user-visible, and the PR template’s checklist filled in.

Local gates

scripts/test-all.sh          # fmt, clippy, tests, benches, validate, report
scripts/validate-scenarios.sh
scripts/run-happy-paths.sh   # … and the other per-family runners
scripts/run-fuzz.sh 42 50

Definition of a done change

  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test --workspace
  • scenario/vector ids registered, deterministic, machine-readable
  • regression scenarios added for any fixed bug (never deleted)
  • docs updated in the same commit

Cross-repository pinning

Crucible is three repositories that compile each other, and each dependency edge is pinned to an immutable revision rather than a version range. That is deliberate — a moving upstream would silently change what this repository’s conformance results mean — but it creates one obligation: the pinned set has to be recorded somewhere a reviewer can read, and it has to be impossible to move a pin without moving the record with it.

This document is that record, and a test in this repository fails when it goes stale.

What each repository owns

RepositoryRoleConsumes
crucible-simulatorSIMULATE — deterministic state and execution modelnothing
crucible-proverPROVE — circuits, witnesses, proving, verificationcrucible-simulator (proof seam)
crucible-scenariosSTRESS-TEST — orchestration, conformance, adversarial, privacy, regression, reportingcrucible-simulator, crucible-prover

The graph is acyclic and one-way: nothing upstream depends on anything downstream, so a pin can only ever be bumped deliberately, never to resolve a cycle.

The pinned set in force

Every revision below is a 40-character commit SHA. The table is ordered by consumer.

ConsumerDependencyPinned revision
crucible-scenarioscrucible-prover983ed400ddd3ba9c0ddad1ab6a41e471c6ab3c8c
crucible-scenarioscrucible-simulator7fe82b7c8fc7e2f7bece6a6af36016d8229b3fc3
crucible-provercrucible-simulator7fe82b7c8fc7e2f7bece6a6af36016d8229b3fc3

Both consumers name the same crucible-simulator revision. Nothing upstream forces that — it is a property this repository chooses to hold, and the test below fails if a bump here is not mirrored in the prover row.

What the test enforces

crates/adapters/prover/tests/pinned_revisions.rs reads the manifests of this workspace and this document and asserts that:

  1. every rev = "<sha>" pin in this workspace appears in the table above, and
  2. every revision the table attributes to a crucible-simulator dependency is the same revision this workspace pins, so a simulator bump here cannot land while the crucible-prover row still names the old commit.

It is a drift detector, not a resolver: it reads files only and never touches the network, so it runs in the ordinary cargo test job.

Why a revision pin and not a version

A semantic-version range would let a dependency change under this repository between two CI runs on the same commit. Every claim this project makes is a determinism or conformance claim, and a claim that can be invalidated by someone else’s release is not a claim. Revision pins make the compiled graph a pure function of these repositories’ own history — which is exactly the property the audit trail depends on.

How a pin moves

  1. Change the revision in the manifest that owns it. Here that is crates/adapters/simulator/Cargo.toml and crates/adapters/prover/Cargo.toml; in crucible-prover the simulator crates resolve through [workspace.dependencies] in the root manifest, so a bump there is a single edit.
  2. Run the consuming repository’s own test suite. Its CI compiles the pinned revision, so the consuming repository is what validates the combination.
  3. Update this table in the same commit. Step 3 is enforced: the test fails otherwise.

Honest limitation

There is no umbrella repository and no cross-repository CI job. Each consuming repository validates its own pins by compiling them, which is a real check, but nothing here verifies that the prover’s manifest still names the simulator revision in the third row — only that the table agrees with itself. A prover bump to a new simulator revision would be caught when the prover’s own CI builds, not by this test.

Closing that properly means a fourth repository whose only job is to check out all three at the revisions named here and build them together. That is an org-level decision, not something either repository can do alone, and it is recorded here rather than implied.

Definition-of-Done audit

This document scores the repository against the specification’s §76 Definition of Done at each milestone. It is maintained honestly: an item is marked done only when the corresponding capability is real, registered, and verified — never when it is merely designed. The audit is a living document; re-run the checks below before updating a row.

Last audited: real simulator wiring (RealSimulator over the crucible-simulator flow engine, run_real_simulator / run_integration on the harness).

Scorecard

#DoD itemStatusEvidence
1Scenarios can be registeredscenario-registry, 48 scenarios registered across ten packs — 43 judged by the in-memory double, 5 (conformance-real) by the real crucible-simulator engine (crucible-scenarios list)
2Scenarios can be discoveredregistry + list with filters
3Scenarios can be filteredlist/run --category/--tag/--pack
4Scenarios execute deterministicallyfixed-clock harness; byte-stable outcomes; parallel ≡ serial (tested)
5Simulator integration worksRealSimulator drives the real crucible-simulator flow engine ([RealSimulator], pinned revision): operations translated onto the engine’s lifecycle API, real state root/event codes/error codes reported, closed public observation vocabulary; exercised via MockHarness::run_real_simulator and end-to-end with the prover via MockHarness::run_integration
6Prover integration worksprover adapter wires the real crucible-prover service ([RealProver], pinned revision): ABI preflight, envelopes, mandatory round-trip on the hermetic mock backend; exercised via MockHarness::run_real
7Proof verification can be assertedproof-valid/invalid assertions; verification classification in packs
8State transitions can be assertedbalance/ownership/commitment/state assertions + invariants
9Happy paths exist for supported operationsflows catalog: register, deposit, merge, transfer, withdraw, lifecycle (CT-HAPPY-*)
10Negative paths existCT-NEG-001..011
11Adversarial paths existCT-ADV-001..004
12Replay scenarios existCT-ADV-003, CT-NEG-008, CT-REG-003
13Stale-state scenarios existCT-ADV-003, CT-NEG-002 family
14Public-input mutation scenarios existCT-ADV-002, CT-ADV-004
15Proof-tampering scenarios existCT-ADV-001, negative invalid/tampered/malformed
16Privacy tests existCT-PRIV-001..004 + definition-time guard + privacy invariant
17Invariants existseven built-in checks, attached across packs
18Conformance tests existCT-CONF-001..005 (against doubles; see note)
19Regression tests existCT-REG-001..003 from real bugs; never deleted
20Fuzz targets existfour targets, seeded, finding→reduction→CT-REG pipeline
21Concurrency scenarios existCT-CONC-001..004 (sequential composition) + --parallel runner
22Test vectors existCT-VEC-001..024 corpus, validated for coherence
23Results are machine-readableJSON, JUnit XML, Markdown; CI-usable exit codes
24Failures are reproducibleseed + environment on every outcome; deterministic replay
25Private data is not leakedredaction by construction; privacy pack + invariant + reporter audit
26Mock tests clearly distinguished from real crypto testsdoubles labeled everywhere; docs/PR template enforce
27Soroban integration isolated behind adaptersadapters/soroban (contract surface, translation, events; client not wired)
28Testnet integration is optionaladapters/testnet opt-in; never required by CI
29CI covers appropriate scenario classesLevel-1/2 + conformance/adversarial/regression/performance/security/testnet/release
30Documentation explains the architecture27 docs + README status maps
31Contributors have clear issue surfaces8 issue templates + PR template + chooser
32No simulator implementation duplicatedboundary enforced; no state engine in scenarios
33No prover implementation duplicatedboundary enforced; no proving in scenarios
34No unsupported protocol semantics inventedagent scenarios deferred (no agent protocol); replay/etc. per declared model

Done: 34 of 34. DoD 5 and 6 are both closed at the integration level: the real crucible-simulator flow engine and the real crucible-prover service machinery each run behind their adapter, together in one run via MockHarness::run_integration, and CT-CONF-R01..R05 state conformance contracts the engine itself judges. The one caveat that survives is the proving backend: it is crucible-prover’s deterministic mock, so no scenario here is yet a cryptographic conformance check, and real UltraHonk/bb proving remains an opt-in exercised in crucible-prover’s own dedicated CI. Everything else marked done is verifiable with the commands below.

How to verify the done claims

scripts/test-all.sh                 # fmt, clippy, tests, benches, validate, report
cargo test --workspace              # 376 tests
target/debug/crucible-scenarios validate       # 48 scenarios + 24 vectors + 2 declarative docs
target/debug/crucible-scenarios report         # 29 pass, 19 expected failures, 0 failed
target/debug/crucible-scenarios run --tag real-engine --real-engine  # 5 contracts judged by the real engine
target/debug/crucible-scenarios report --parallel  # byte-identical to serial
target/debug/crucible-scenarios list --category conformance
target/debug/crucible-scenarios fuzz --seed 42 --iterations 100
cargo bench                         # six benches
cargo test -p scenario-format committed_example_documents_parse_and_validate

Conformance caveat (DoD 18)

The conformance category holds two packs, and a report says which system judged each contract:

  • conformance (CT-CONF-001..005) — stated against the deterministic in-memory double, asserting the double’s vocabulary (balance.<actor>.<token>, fixture commitment ids, ct_* event codes). These exercise orchestration and binding semantics.
  • conformance-real (CT-CONF-R01..R05) — stated against the real crucible-simulator flow engine, asserting only facts the engine publishes (op.<id>.accepted, its own event codes, and its public state counts). These are judged by the engine itself, via MockHarness::run_real_simulator, and crucible-scenarios report now includes them: 5 contracts judged by crucible-simulator, 43 by the double.

A test asserts that the real-engine contracts are not all satisfiable by the double, so the distinction they draw is real rather than nominal.

What is still not done — and the reason this caveat stays — is the proving axis. Contracts still run against crucible-prover’s hermetic mock backend; real UltraHonk/bb proving is opt-in and heavyweight in the prover repository, so no scenario here is yet a cryptographic conformance check. That is the remaining work, and it is gated on the same external audit the prover repository needs.

A real run is never a silent substitution: RealSimulator reports its own adapter name, run_real_simulator/run_integration are separate entry points from run, the CLI refuses to judge a double-backed contract with the real engine, and announces any real-engine contract it skips.

Change log

  • Privacy/concurrency/test-vectors batch — closed DoD 16 (privacy tests), 21 (concurrency scenarios), 22 (test vectors).
  • Regression/fuzz batch — closed DoD 19 (regression tests), 20 (fuzz targets).
  • Adapters/packs/reporting/declarative/parallel/CI batch — closed DoD 27 (Soroban adapter isolation) and 28 (opt-in testnet), extended DoD 29 (dedicated CI levels), added the reporting, declarative-format, and parallel-execution surfaces, and completed DoD 31 (contributor issue surfaces).
  • Benches/scripts/docs batch — completed DoD 30 (full documentation set and accurate status maps), added the six benches, the script set, and the worked-examples map, and produced this audit document.

DoD 5–6 were open at every milestone and blocked on the upstream repositories. DoD 6 was closed by wiring the real crucible-prover service behind the prover adapter (pinned revision, hermetic mock backend) and raising the workspace MSRV to 1.98 to compile the edition-2024 prover crates. DoD 5 was closed by adding RealSimulator over the real crucible-simulator flow engine (pinned revision) and exposing run_real_simulator / run_integration on the harness, so the SIMULATE and PROVE seams are both real and can be driven together in one run. The conformance pack itself is still evaluated against the double; see the conformance caveat above for the remaining work and why it is deliberate.

Error codes

Every failure this harness can raise carries a stable machine-readable code alongside its human-readable message. The reporting layer classifies outcomes by code, the JUnit and Markdown renderers group by it, and scenario assertions branch on it, so the code — not the message text — is the interface.

Two properties this document exists to guarantee:

  • Every code the harness can raise is listed here, so a consumer that observes one in a report can look up what it means without reading the source.
  • Every code listed here exists, because a stale table is worse than no table: consumers trust it.

Both are enforced by scripts/check-error-codes.py, which discovers every crate’s code() method, extracts the codes it can return, and compares them against the tables below. It runs in CI, so drift is a build failure.

Naming, and how it differs from the simulator

Codes here are UPPER_SNAKE_CASE (ASSERTION_TYPE_MISMATCH). The crucible-simulator domain codes are snake_case (insufficient_balance), and those codes pass through this harness unchanged — an adapter never re-codes a simulator failure, so one condition always reports one code.

The two conventions are currently a family inconsistency rather than a deliberate distinction; harmonising them is tracked separately. Until then, treat an UPPER_SNAKE_CASE code as a harness failure (this repository’s own machinery: definitions, fixtures, adapters, assertions, the runner) and a snake_case code as a domain failure reported by the system under test.

The stability contract

  • Never rename a code. Reporting, classification and consumer match statements depend on the string.
  • Never reuse a retired code for a different condition.
  • An unrecognised code means “a failure I do not specifically classify” and must be reported as such, not treated as a panic or an unreachable branch.

Harness definitions and registry

scenario-core

Definition-time validation: a scenario, its metadata, or its environment is structurally wrong, so it is rejected before anything executes.

CodeRetryableRaised when
INVALID_IDnoA scenario id is malformed or does not match the required shape.
DUPLICATE_IDnoA scenario id is registered twice.
INVALID_METADATAnoScenario metadata is missing a required field or is inconsistent.
UNKNOWN_ACTORnoA step references an actor the scenario never declares.
UNKNOWN_TOKENnoA step references a token the scenario never declares.
UNKNOWN_STATEnoA step references a named state that was never captured.
UNKNOWN_REFERENCEnoA step references a value, snapshot, or fixture that does not exist.
UNAVAILABLE_CAPABILITYnoThe scenario requires a capability the harness does not provide.
UNSUPPORTED_ENVIRONMENTnoThe scenario targets an environment this harness cannot build.
INVALID_SEEDnoA seed is malformed or out of range.
INVALID_TIMEOUTnoA timeout value is malformed or not a positive duration.
CONFIDENTIAL_PUBLIC_INPUTnoPrivacy guard: a proof-generation step’s public inputs name a confidential field. Rejected at definition time so private material cannot be published by construction, not merely by convention.
SERIALIZATIONnoEncoding or decoding a scenario, vector, or result failed.

scenario-registry

CodeRetryableRaised when
DUPLICATE_SCENARIOnoA scenario with that id is already registered.
UNKNOWN_SCENARIOnoA lookup, filter, or replay names a scenario that is not registered.
INVALID_FILTERnoA registry filter (category, tag, capability, id prefix) is malformed.

Runner

scenario-runner

Execution, lifecycle, and isolation failures. These describe the harness’s own behaviour around a scenario, not a verdict about the system under test.

CodeRetryableRaised when
CONFIGURATIONnoThe runner was configured inconsistently (for example, parallel workers with serial-only scenarios).
SCENARIO_DEFINITIONnoThe definition is valid per scenario-core but unusable as written.
ENVIRONMENTnoBuilding the scenario environment failed.
TIMEOUTyesThe scenario exceeded its timeout. Retryable only because a timing-dependent failure may not recur; a deterministic failure never will, and is classified as such.
CANCELLEDyesThe run was cancelled before the scenario completed. Classified distinctly from a failure, so an abort is never reported as a defect.
RETRY_EXHAUSTEDnoThe declared retry limit was reached.
NON_RETRYABLEnoA failure occurred that the retry policy refuses to retry, by design: deterministic failures are never retried.
ISOLATIONnoA scenario’s state leaked into another, or an isolation boundary was violated.
CLEANUPnoA cleanup hook failed after the scenario ran.
HOOKnoA lifecycle hook failed.
INFRASTRUCTUREyesA transport or harness-level dependency failed. Distinct from NON_RETRYABLE precisely so the retry policy can treat the two differently.

Assertions and fixtures

assertions

CodeRetryableRaised when
ASSERTION_MISSING_OBSERVATIONnoAn assertion expected an observation the run never produced. Usually a scenario bug rather than a system failure.
ASSERTION_TYPE_MISMATCHnoThe observed value’s type does not match what the assertion compares against.
ASSERTION_MISSING_FIXTUREnoAn assertion references fixture data that does not exist.
ASSERTION_INTERNALnoAn internal inconsistency in the assertion engine — a bug here, not a finding.

fixtures

CodeRetryableRaised when
FIXTURE_MISSINGnoA referenced fixture is absent from the catalog.
FIXTURE_MALFORMEDnoA fixture failed to parse or violates its schema.
FIXTURE_VERSION_MISMATCHnoA fixture’s declared version is not one this harness supports.
FIXTURE_INTERNALnoAn internal inconsistency while loading fixtures.

Adapters

Adapters translate the harness’s operation vocabulary into a backing surface. A translation failure is an adapter fault: it means the operation could not be expressed, which is a different finding from the surface rejecting it.

adapters/simulator

CodeRetryableRaised when
INVALID_OPERATIONnoThe operation could not be translated into a simulator call.

adapters/prover

CodeRetryableRaised when
NO_PROOF_FIXTUREnoA proof was required but the pack provides no fixture posture for it.
GENERATIONnoProof generation through the prover surface failed.
UNSUPPORTED_OPERATIONnoThe prover surface has no handling for this operation.

adapters/soroban

CodeRetryableRaised when
UNKNOWN_CONTRACTnoThe contract surface names a function on a contract it does not know.
UNKNOWN_FUNCTIONnoThe operation has no contract-function translation.
MISSING_ARGUMENTnoA call was translated without a required argument.
INVALID_ARGUMENTnoAn argument does not match the call’s expected type.
CONFIDENTIAL_INVOCATIONnoPrivacy guard: an invocation would place confidential material in publicly visible contract arguments.

adapters/testnet

CodeRetryableRaised when
TESTNET_NOT_CONFIGUREDnoThe live-network path was used without explicit opt-in configuration. The adapter never silently falls back to a network.
INVALID_CONFIGURATIONnoThe network configuration is malformed (bad RPC URL, unsupported network).
INVALID_EXECUTIONnoA network execution attempt is structurally invalid.
POLL_TIMEOUTyesPolling for a transaction result exceeded its budget. The network may simply be slow.
MALFORMED_STATUSnoA network status response did not match the expected shape.

Codes shared across crates

Two codes appear in more than one module on purpose: the same condition has the same name wherever it surfaces, so a consumer never has to special-case which crate produced it.

CodeRetryableRaised when
FIXTURE_INCONSISTENCYnoA fixture contradicts another fixture or a declared expectation. Raised by the simulator and prover adapters as well as the Soroban adapter.
INTERNALnoAn internal inconsistency indicating a bug in this repository rather than a finding about the system under test. Raised by every module that defines codes.

Using codes

#![allow(unused)]
fn main() {
if outcome.code() == "CONFIDENTIAL_PUBLIC_INPUT" {
    // A privacy guard tripped: this is a finding about the scenario, not a
    // flaky run, so it must never be retried or classified as infrastructure.
    report(Classification::PrivacyViolation);
}
}

Prefer the typed error variant inside Rust, where match is exhaustive and the compiler enforces it. Codes exist for the surfaces a type cannot reach: the JSON report, the JUnit XML a CI server ingests, the Markdown summary, and the exit code of the CLI.

Execution model

Execution is owned by crates/scenario-runner (introduced after scenario-core). This document records the model the runner implements.

Lifecycle

Every scenario follows the same stages; a failure at any stage is classified with the stage it happened in:

  1. Discover — find the scenario through the registry.
  2. Validate — the scenario was already validated at build time; re-check against the current context (capabilities, environment, isolation).
  3. Prepare — prepare environment/fixtures; nothing here may run the scenario early.
  4. Initialize — build the ScenarioContext, load initial state, seed the deterministic stream.
  5. Execute — run the ordered operations through the context’s services.
  6. Observe — capture results as classified observations.
  7. Assert — evaluate the scenario’s assertions.
  8. Invariants — evaluate declared cross-operation invariants.
  9. Classify — produce the ScenarioOutcome with a Status and, on failure, a classified Failure.
  10. Report — hand the outcome to reporting.
  11. Cleanup — tear down in all paths.

Determinism and replay

  • Every randomized scenario carries a Seed; the seed is reported with the outcome.
  • Consumers derive isolated child seeds (ScenarioContext::seed_for) so fixtures, generators, and sequences never interfere.
  • A dedicated replay command (crucible-scenarios replay --scenario <ID> --seed <SEED>) is planned; today determinism comes from the harness itself (fresh isolated simulator per run, fixed clock, validated definitions), so a re-run of the same scenario reproduces the same observations and outcome without a seed.
  • Replay output never contains secrets.

Retries

Retries are permitted only where a scenario explicitly allows them, and must never hide nondeterministic failures: for deterministic scenarios a retry is normally suspicious. Security-sensitive failures (authorization, privacy, unexpected acceptance, verification) must never be retried away.

Parallel execution vs. protocol concurrency

The model distinguishes parallel execution (multiple scenarios run in parallel threads — an orchestration concern) from valid protocol concurrency (operations racing against the same state — a scenario concern requiring the concurrency capability). Parallel execution is not yet implemented: the harness runs one isolated scenario at a time, and protocol concurrency is modeled at the scenario level (a single-threaded surface executes racing operations sequentially and asserts the outcome). Parallel execution, when added, will never be assumed semantically safe on its own; expected behavior stays defined per scenario.

Timeouts and cancellation

Each scenario may declare a positive timeout (milliseconds). A run exceeding it is classified TIMEOUT at the executing stage. Runs may be cancelled, classified CANCELLED. Both are distinct from FAIL and from ERROR (harness problems) so reports can tell them apart.

Failure classification

FailureCategory distinguishes harness defects (scenario-definition, fixture, environment, infrastructure) from findings about the system under test (assertion, invariant, proof, verification, state, authorization, privacy, compatibility, unexpected acceptance). Security-sensitive categories default to elevated severities and are flagged, so they can never be buried.

Statuses

PASS, FAIL, SKIPPED, EXPECTED_FAILURE, ERROR, TIMEOUT, CANCELLED. EXPECTED_FAILURE (the declared failure occurred exactly as declared) counts as a pass; SKIPPED is the honest result of a capability or environment mismatch, never a silent mis-execution.

Fixtures

Fixtures live in the fixtures crate as a synthetic, embedded, deterministic catalog — never real wallet material, never secrets. Every type in the catalog is constructed so no field can hold a private value.

Catalog

FixtureCatalog (crates/fixtures/src/catalog.rs) is the embedded default:

  • Accounts — alice, bob, carol, issuer, admin, auditor, plus registerable accounts (e.g. dave). Each has a role, a registration flag, a frozen flag, and synthetic public metadata.
  • Tokensct-usdc, ct-eurc, native.
  • Balances — keyed by actor.token; the starting public ledger each scenario’s conservation invariant is checked against.
  • Commitments — confidential-state fixtures with a digest and a status (live / spent / malformed).
  • Transactions — prior transaction fixtures for replay and duplicate-content scenarios.
  • Proofs — proof fixtures whose posture (valid / invalid / tampered / malformed) drives the prover double’s verification behavior, plus their pinned circuit version.
  • Policies — frozen-account and authorization postures.

Why code-defined?

The spec (§69) requires fixtures to be deterministic, synthetic, versioned, documented, and reusable. Embedding them in the crate makes them:

  • deterministic — no filesystem or environment dependence in tests,
  • type-checked — a malformed fixture is a compile error, not a runtime surprise,
  • the single source of truth — the JSON artifacts under fixtures/generated/ and test-vectors/generated/ are derived from the code-defined catalog by scripts/generate-*.sh, so external tooling consumes exactly what the tests use.

Adding a fixture

Extend the embedded catalog and add a test that asserts the shape. A new fixture must never carry a key, seed, witness, or confidential amount.

Fixtures vs. oracle

Fixtures define the starting state and the posture of test doubles. Expected outcomes are computed independently by scenarios from the declared inputs (balance arithmetic, ownership rules) — never by asking the surface under test what the answer is.

Fuzzing

Fuzzing here is seeded and deterministic by design (spec §26). Each target is a loop over the deterministic mock harness driven by a dependency-free SplitMix64 PRNG derived from a single seed, so target + seed replays every iteration byte-for-byte. A finding records the seed, the iteration, and the exact operations that reproduced it — and reduces to a permanent regression case.

Targets

The fuzz crate runs four targets (spec §26 boundaries):

TargetWhat it mutatesOracle
operationsrandom multi-operation scenarios (actors, tokens, amounts, op kinds)cross-operation invariant registry (conservation, ownership, commitment consistency, replay protection, privacy) + no panic/error
negative-controlsmust-reject operations from the declared posture (unregistered actor, zero amounts, frozen account)rejection expectations — acceptance is a finding
proof-referencesproof reference strings (wrong digest, unknown fixture, malformed, not-a-reference)rejection + input-binding invariant — anything that verifies is a finding
public-inputspublic inputs of a valid proof (recipient/sender/token swaps, appended pairs, empties, permutations)mutations must fail verification; pristine and permuted statements must keep verifying (order independence)

The oracle is always independent of the system under test — the invariant registry recomputes expected facts from the scenario definition and fixture posture, and the controls come from the declared protocol posture. Never from the surface’s own answers.

Running

cargo run -p cli --bin crucible-scenarios -- fuzz                 # seed 42, 200 iterations per target
cargo run -p cli --bin crucible-scenarios -- fuzz --seed 7 --iterations 500 --json

Exit code is non-zero when anything was found; --json emits the full reports including the reproducing operations.

From finding to regression test

  1. A finding records seed + iteration — replay the run with that seed.
  2. fuzz::reduce_finding delta-debugs the operation sequence (drop an operation whenever the failure still reproduces) and shrinks amounts by halving, always re-running the candidate through the harness.
  3. FuzzFinding::to_regression_case converts the reduced finding into a permanent CT-REG-100..999 case that pins the fixed behavior.

Scope and honesty

Fuzzing runs against the repository’s deterministic test doubles. It finds bugs in the scenario layer — broken invariants, unexpected acceptances, verification binding slips, panics, leaks. It does not validate the underlying Confidential Token implementation or any real prover; that requires wiring the real simulator/prover and is future work behind the adapter contracts.

Happy-path scenarios

Happy paths prove that the supported Confidential Token workflow succeeds and that every effect is what the protocol rules say it must be — never just “the transaction did not error.”

Flows catalog

The flows crate builds the happy-path catalog (CT-HAPPY-*, pack happy-path), six registered scenarios: register, deposit, merge, transfer, withdraw, and the full register→deposit→merge→transfer→withdraw lifecycle. They are plain Scenarios and run through the same harness and report gate as every other pack.

What every happy path asserts

Beyond “the operation was accepted,” each flow asserts:

  • the expected public event was emitted (ct_register, ct_deposit, …),
  • balances moved by the independently computed amount — the scenario computes expected balances from the declared inputs and fixture starting state, never by asking the simulator what happened,
  • ownership moved to the correct owner where the protocol defines it,
  • proofs generated for a transfer verify against the transfer’s statement before the transfer executes,
  • the full-lifecycle scenario additionally runs with every built-in invariant attached (conservation, ownership, commitment consistency, proof binding, input binding, replay protection, privacy) — a lifecycle that passes its own assertions but violates conservation is a failure.

Anti-circular testing

Expected values are derived from declared inputs and fixture starting state (e.g. deposit 40 on a fixture balance of 100 ⇒ 140), not from the simulator’s own reported results. The pack tests require each scenario to pass with all invariants, so the happy path is checked from two independent directions.

Invariants

Invariants (crates/invariants, runner interface in scenario-runner) are cross-operation checks judged over a whole run from the scenario definition and the recorded observations — a second, independent lens that per-step assertions cannot provide.

The seven built-in checks

Stable registry keys (referenced by scenarios via invariant_ids):

KeyChecks that …
conservationtotal declared value is conserved across the run’s operations, computed from the declared amounts and the fixture starting ledger.
ownershipstate stays with the owner the operations imply, per protocol rules.
commitment-consistencycommitment observations correspond to valid state relationships declared by the scenario.
proof-bindingeach proof generated in the run binds to the operation it claims to prove.
input-bindingpublic-input mutations (adversarial runs) invalidate verification; binding is checked, not assumed.
replay-protectionconsumed or stale state is never reused.
privacyno public observation key carries a confidential field name (amount/witness/secret/…).

Evaluation model

The runner’s InvariantRegistry::check_all(scenario, observations) returns named InvariantChecks (id, held, safe detail). The executor counts held/violated invariants onto the outcome, and runs that declare an invariant expectation fail if it did not run or did not hold. The registry receives the scenario + observations, never adapter internals — the checks are pure functions of public evidence.

What is not asserted

Invariants only assert properties the protocol actually guarantees through the declared model. Nothing is invented: if the underlying implementation does not define a guarantee, no invariant claims it (spec §20, §34).

Use

  • Happy-path and conformance scenarios attach all seven and must pass.
  • The fuzz targets run every run through the full registry, so a fuzzed sequence that violates conservation or binding surfaces as a finding.
  • Negative/adversarial packs attach invariants where the guarantee should hold despite the expected rejection.

Negative testing

Negative scenarios (crates/negative, pack negative) prove that inputs which must be rejected are rejected, with the rejection classified and the expected outcome explicitly declared — never assumed.

What is covered (CT-NEG-001..011)

  • Invalid proof — corrupted, invalid-fixture, malformed, and tampered proofs must fail verification with the right classification.
  • Wrong public inputs / ownership — a proof bound to the wrong sender/recipient/token is refused; an operation by the wrong owner fails authorization.
  • Insufficient balance / invalid amount — overdrafts and invalid amounts are refused with a classified state failure.
  • Stale state — an operation against already-consumed or stale state is refused.
  • Replay / unauthorized / frozen — replayed operations, unauthorized actors, and frozen accounts are refused.
  • Invalid commitments — operations referencing a malformed or inconsistent commitment are refused.

Declared outcomes

Every negative scenario declares the failure it expects (DeclaredOutcome::Fails(FailureCategory::…)). A run passes only when the system fails exactly as declared: a negative scenario that succeeds is an unexpected acceptance and fails the gate. This is what makes the negative pack a real gate rather than a collection of “this errors” assertions.

Classification

Rejections carry a stable category — EXPECTED_REJECTION, VERIFICATION_FAILURE, STATE_FAILURE, AUTHORIZATION_FAILURE, INSUFFICIENT_BALANCE — surfaced in outcomes, reports, and CI. The mock harness reports a refused operation as an outcome, never as a panic, so the expected-vs-actual comparison is structural.

Boundaries

The scenarios orchestrate operations and assert on classified outcomes. They implement no protocol logic themselves — a rejection reason comes from the adapter double’s deterministic semantics, and the expectation comes from the declared input, never from asking the surface under test.

Performance testing

Performance work splits into two honest halves: correctness at scale (the registered performance pack) and measurable timing (the benches).

The performance pack (CT-PERF-001..003)

A configurable high-volume scenario family that proves the orchestration stack stays correct as workloads grow — repeated deposits, repeated transfers, and the full register→deposit→transfer→withdraw lifecycle over many operations. Each scenario computes expected balances independently (deposit count × unit amount against the fixture ledger) and attaches the full invariant registry, so “fast but wrong” is still a failure. Scale is configurable (performance::all_with(PerformanceParams{..})) so CI runs a bounded scale while an operator can push higher locally.

The benches

cargo bench runs six stable-Rust benches (no external benchmarking dependency) under benches/:

  • scenario-execution — end-to-end scenario runs through the mock harness (ns/run),
  • assertion-evaluation — assertion-engine evaluation over a populated observation log (ns/eval),
  • fixture-loading — catalog construction and per-run clone cost (ns/clone),
  • proof-flow — prove / verify / reject-tampered through the prover double,
  • concurrency — serial vs parallel wall-clock speedup with a byte-identical-outcome assertion,
  • reporting — suite-report rendering in JSON / JUnit XML / Markdown with a determinism assertion.

Timings are labeled as mock-harness costs — they bound the scenario layer, never the real prover or simulator.

Phase-decomposed timing

Scenario outcomes carry per-phase timings (setup, simulation, assert, invariants, classify, report, cleanup — spec §67). The reporting crate renders them as a per-phase table in Markdown and JSON so a run’s time is never collapsed into one number. Under the fixed mock clock these are deterministic (zero); with a wall clock they become real and still structured.

Privacy testing

Privacy is tested at the scenario-framework level: the guarantee under test is that the scenario layer never exposes confidential values on its own public surfaces — observations, logs, serialized results, reports, or failure diagnostics. Nothing here tests the cryptography of the underlying Confidential Token implementation; it tests the surfaces this repository controls.

Threat model

The leaks this suite defends against are accidental, not cryptographic:

  • a working pipeline that serializes its inputs into a report;
  • an error path that echoes the rejected payload back in its diagnostic;
  • a proof step that copies witness material into public inputs;
  • a log line that prints an operation or observation with {:?}.

Failure paths are treated as more dangerous than success paths, because error handling is where payloads get echoed.

Layered enforcement

No single mechanism carries the guarantee; each layer independently catches a different failure:

  1. Type-level classification (scenario-core::observation) — every observation carries a Visibility. Non-public values serialize as [REDACTED], and Debug never renders them. A leak would require deliberately authoring a public observation with a private value.
  2. Definition-time guard (scenario-core::scenario) — a GenerateProof step whose public inputs name a confidential field (amount, witness, secret, nonce, opening, randomness, confidential) fails to build. Confidential values never belong in public inputs.
  3. Runner expectationsExpectationKind::NotDisclosed pins specific keys (e.g. op.op-transfer.amount) that must never become observations.
  4. Privacy invariant (invariants, key privacy) — scans every public observation key for confidential markers; a public observation whose key names a confidential concept would leak structure even with a redacted value.
  5. Report hygiene (pack tests) — whole ScenarioOutcomes are serialized to JSON and the confidential literals are asserted absent on both success and failure paths.

Scenarios

The privacy pack (CT-PRIV-001..004) exercises both paths:

IdPathWhat it pins
CT-PRIV-001successa confidential transfer completes while the 30-unit amount and witness material never become public observations
CT-PRIV-002failurea rejected transfer discloses only its classification code, never the attempted 9000-unit amount
CT-PRIV-003proofthe proof statement binds only public protocol values; generation and verification observations never name amount/witness
CT-PRIV-004failurea tampered proof fails verification without leaking witness material

Run them with:

cargo run -p cli --bin crucible-scenarios -- run --category privacy
cargo run -p cli --bin crucible-scenarios -- report

What is and is not guaranteed

  • Guaranteed: no confidential amount or witness value enters an observation, a serialized outcome, or a failure diagnostic of this framework; no proof-generation step can declare confidential public inputs; fixtures contain no secret-carrying fields.
  • Not guaranteed: cryptographic privacy of the underlying protocol — that belongs to the Confidential Token implementation and the real prover. Re-validate against the real crucible-simulator/crucible-prover before making external privacy claims.

Proof testing

Proof testing covers how scenarios generate, verify, tamper with, and bind proofs — and what those checks mean given the current adapter.

The prover adapter boundary

crates/adapters/prover implements scenario-core’s ProofProviderService / VerifierService contracts. Today it is a deterministic fixture double: generation names the proof fixture the operation declares and binds the proof to the statement’s public inputs via a fingerprint; verification recomputes that fingerprint from the request and reports a stable reason (tampered-proof, malformed-proof, input-binding-mismatch, state-binding-mismatch, wrong-circuit, unknown-proof) when the fixture posture or the binding disagrees.

This is not cryptographic verification. Packs built on the double exercise orchestration and binding semantics. Wiring crucible-prover behind the same contracts makes the same scenarios real verification checks without changing them.

What the packs exercise

  • Valid proofs — a transfer’s generated proof verifies against its statement before the transfer executes (flows, conformance).
  • Invalid / tampered / malformed proofs — negative pack: each posture fails verification with the specific reason.
  • Public-input mutation — adversarial pack: changing a bound input invalidates the proof; permuting inputs does not change the verdict.
  • Stale proofs — adversarial pack: a proof generated against state A is refused after the state moves to B.
  • Version compatibilitycrates/compatibility: a proof verifies against the same circuit version, is refused as wrong-circuit against a different version, and identical statements produce deterministic artifacts.
  • Replay protection — regression pack pins the fixed behavior: duplicate content under a new id is refused.

Privacy

Proof public inputs carry only public protocol values (token, sender, recipient, circuit). scenario-core rejects at definition time any GenerateProof step whose public inputs name a confidential field (amount/witness/secret/…), so confidential values never cross the proof interface.

Prover integration

The prover adapter (crates/adapters/prover) is the surface for proof generation and verification. It implements scenario-core’s ProofProviderService and VerifierService contracts in two ways.

The deterministic fixture double (posture scenarios)

FixtureProver is backed by proof fixtures whose posture (valid / invalid / tampered / malformed) drives outcomes, and binds each proof to its statement via a fingerprint over the public inputs and optional state digest. Verification recomputes the fingerprint from the request and reports a stable reason when it disagrees or the posture says the proof is bad. Circuit-version checks are modeled: a request pinning a different version is refused as wrong-circuit.

Negative and adversarial scenarios need a prover that can emit an invalid, tampered, or malformed proof on demand; only the double can do that, so posture scenarios keep running against it.

The real adapter (crucible-prover service machinery)

RealProver replaces the double behind the same contracts wherever the scenario must exercise the prover’s actual pipeline. It depends on the crucible-prover repository (pinned revision) and runs requests through ProverService:

  • canonical ABI-complete requests per protocol operation (crucible-prover’s own fixtures, the same request vocabulary its tests and canaries use),
  • preflight validation — a request missing circuit-ABI private or public names is rejected before any backend runs,
  • provider dispatch and versioned ProofEnvelope assembly,
  • a mandatory local verification round-trip: a proof that fails its own round-trip is never handed onward,
  • the real verification-key digest from the produced envelope.

The registered provider is crucible-prover’s deterministic mock backend, so the real pipeline runs hermetically in CI — exactly the backend crucible-prover’s own default CI uses.

Neither adapter is a cryptographic prover. The fixture double exercises orchestration and binding semantics; the real adapter exercises the genuine prover service contract (request validation, envelope assembly, round-trip verification, precise failure reasons). Proofs produced by either must never be described as cryptographically valid (spec §53); the repository’s docs, PR template, and report labels all say so.

Real-crypto separation

Runs over the fixture double and the real (mock-backend) adapter are both mock validation. The test-vectors corpus labels proof behavior expectations (valid/invalid/tampered/malformed fixtures) as fixture-driven. When real UltraHonk/bb proving is exercised (in crucible-prover’s dedicated CI), the same adversarial mutations (proof tampering, public-input mutation, wrong circuit, stale state) run against real verification. Wiring real UltraHonk behind these same contracts would make the conformance pack real behavior vs an independent oracle — the remaining step after the simulator ledger is unified through a real adapter.

Boundary

No proving logic is implemented in this repository (spec §3). Proof requests carry only public inputs; scenario-core rejects any GenerateProof step whose public inputs name a confidential field, so witnesses never cross this interface.

Regression system

Every discovered correctness or security bug in this repository becomes a permanent regression case. A case is never deleted once the bug is closed — it is the repository’s memory, and it fails loudly if the fix ever stops holding.

The case model

A RegressionCase pairs:

  • a stable regression id (CT-REG-001 …), independent of the issue tracker;
  • the original issue reference;
  • the affected component (which crate/module the bug lived in);
  • the version or commit the fix landed in;
  • the reconstructed minimal scenario that pins the fixed behavior — it passes with the fix in place and fails while the bug is open.

Regression scenarios are Category::Regression, registered with regression provenance, and judged with the built-in invariant registry attached: a regression is a regression if the fix stops holding.

Current cases

IdBugWhat the case pins
CT-REG-001the assertion engine read the first observation for keys that are re-recorded as a run progressestwo deposits of 40 then 10 assert the final public balance of 50
CT-REG-002an unregistered actor acting on another owner’s state was not refused by the registration check firstmallory’s transfer of alice’s state is refused as unregistered-account, not wrong-owner
CT-REG-003the ledger’s replay protection keyed on the operation idresubmitting identical content under a new id is refused as duplicate-submission

Run them with:

cargo run -p cli --bin crucible-scenarios -- run --category regression
cargo run -p cli --bin crucible-scenarios -- report

Adding a regression case

When a bug is found (by hand or by the fuzzer):

  1. Reconstruct the minimal operations that reproduce it.
  2. Add a case builder in crates/regression/src/cases.rs whose scenario asserts the fixed behavior.
  3. Register it in regression::all().
  4. For fuzz findings, use fuzz::reduce_finding to shrink to the minimal case and FuzzFinding::to_regression_case to scaffold the case (ids land in CT-REG-100..999 so they never collide with hand-authored ones).

Never delete a regression case because the issue is “fixed” — the point of the case is to stay.

Reproducibility

Every scenario run must be reproducible from its inputs. This repository treats nondeterminism as a defect, not a feature.

What makes a run reproducible

A run is fully determined by:

  • scenario id + version — the definition is code or a validated declarative document (the semantic validator runs before execution),
  • fixture catalog — embedded, deterministic, synthetic,
  • environment configuration — the target Environment,
  • seed — every outcome records the seed it ran with; scenarios never consult system randomness silently,
  • clock — the mock harness injects a fixed clock, so phase timings and outcome stamps are byte-deterministic,
  • the invariant registry — attached invariants are named and counted on the outcome.

Where randomness is allowed

Only in the fuzz targets, and only explicitly: each fuzz run takes a seed, records it on every finding, and replays the same iterations for the same seed. A fuzz finding is reducible to a deterministic regression scenario (CT-REG-*) via the reduction module, satisfying spec §26’s “finding → minimal deterministic regression test” pipeline.

Enforcement

  • report and run outputs are byte-stable: parallel execution returns outcomes re-sorted into registry order, so --parallel reports match serial reports exactly (spec §71).
  • The reporting crate asserts its JSON/JUnit/Markdown renderers are byte-identical across iterations.
  • The concurrency bench asserts parallel outcomes equal serial outcomes.
  • Test vectors are deterministic by construction and validated for coherence.

Replaying a failure

A failing run is captured as a classified ScenarioOutcome (never a panic), carrying the seed, environment, observations, and failure category. Re-run with the same selection and seed to reproduce; file a regression issue referencing the seed and scenario id (see the issue templates).

Declarative scenario format

Scenarios are normally assembled through the typed builders in scenario-core. The scenario-format crate adds the declarative form (spec §33, §68): a versioned JSON envelope around the same typed Scenario model, parsed and validated before execution.

Document shape

{
  "$schema": "https://crucible.dev/schemas/scenario.schema.json",
  "format": "crucible-scenarios/scenario",
  "schema_version": 1,
  "scenario": { "...the Scenario body..." }
}
  • format must equal crucible-scenarios/scenario; anything else is rejected with a structured error.
  • schema_version must equal the version this build supports (1). A newer document is refused, never silently partially read.
  • The envelope is strict: unknown keys are rejected so a typo cannot change document semantics.
  • scenario is the serialized Scenario — field names and shapes are exactly what scenario-core serializes. Reference documents live in examples/declarative/.

Validation before execution

Parsing is validation-first. ScenarioDocument::from_json enforces, in order:

  1. well-formed JSON,
  2. supported format id,
  3. supported schema version,
  4. typed deserialization of the body,
  5. the shared semantic validator (Scenario::validate_semantics): unique operation ids, non-dangling proof targets and expectations, no confidential field names in proof public inputs, positive timeouts, non-empty invariant keys.

The semantic validator is the same one the builder runs, extracted so declarative and programmatic scenarios follow one rule set — a declarative document cannot be “more permissive” than a builder-built one. Invalid documents fail before any execution could mutate state (spec §68).

Schema files

schemas/scenario.schema.json documents the envelope, and schemas/scenario-step.schema.json documents one operation step, for external tooling. The repository’s enforcement point is the typed parser + semantic validator; the JSON Schema files are the interoperable contract.

CI coupling

The canonical examples are embedded in the crate and re-parsed by its tests and by crucible-scenarios validate, so the documented format, the examples, and the parser cannot drift apart.

Scenario model

The scenario model lives in crates/scenario-core. It defines the vocabulary of scenario-based validation and never executes anything.

Core types

TypeMeaning
ScenarioA complete definition: metadata, environment, capabilities, actors, operations, expectations, assertions, invariants, seed, timeout, declared outcome. Validated at build time.
ScenarioIdPermanent, grammar-validated identifier (CT-XFER-001, CT-XFER-NEG-001, CT-PROOF-REPLAY-001, REG-2026-001).
ScenarioMetadataName, description, Category, Tags, pinned protocol/circuit/prover/simulator versions, references.
Actor / ActorId / RolePublic synthetic identities (alice, bob, issuer, auditor, unauthorized…) with declared roles. Actors never carry credentials.
Environment / EnvironmentKindWhere a run executes: mock, simulator, prover, soroban, testnet, end-to-end. Testnet is isolated.
Capability / CapabilitiesWhat a scenario requires vs. what a context offers (simulation, proof-provider, verifier, soroban-adapter, testnet, event-observation, snapshots, concurrency, replay-protection, deterministic-clock).
Operation / OperationKindTyped workflow steps: register, deposit, merge, confidential transfer, withdraw. Intents, not implementations.
ExpectationDeclarative claims: succeeds, rejected (with optional reason), replay-rejected, invariant-holds, not-disclosed.
AssertionSpec / AssertionResultDeclared checks and their per-run outcomes.
Observation / ObservationLogWhat the run saw, classified public/private/sensitive/internal.
ScenarioOutcome / StatusThe per-run record: status, environment, seed, timings, observations, assertion results, invariants, failure.
Failure / FailureCategory / LifecycleStageClassified findings with category, stage, severity.
Severityinfo/low/medium/high/critical with elevated markers for security findings.
SeedDeterministic 64-bit seed; children per consumer; replay key.
ScenarioContextThe runtime handle: clock, rng, actors, event sink, and the stable service contracts (simulator, prover, verifier, soroban, fixtures).

Redaction by construction

ConfidentialAmount renders [redacted] in Debug/Display. Observation values classified private/sensitive/internal serialize only as [REDACTED]; ScenarioOutcome inherits that. Raw private values exist only in memory for trusted executor/assertion code. Scenario definition files may carry expected private values; runtime results must not.

Scenario IDs and stable references

Scenario IDs are permanent and grammar-checked (uppercase A–Z, 0–9, single dashes, ≤ 64 chars). Actor/token/operation identifiers are lowercase slugs. Every expectation/assertion that names an operation is validated against the scenario’s operation list at build time, so dangling references cannot be silently ignored.

Whole-scenario semantics

ScenarioBuilder::declared_outcome states whether the scenario as a whole succeeds or is declared to end in a specific failure category. A declared failure passes only if the system fails exactly that way (Status::ExpectedFailure counts as a pass). Rejections are expected first-class behavior in negative scenarios: an expected rejection is a pass; only unexpected acceptance or unexpected rejection is a defect.

Capabilities and environments

A scenario’s environment implies base capabilities (simulator → simulation/snapshots/event-observation, prover → proof-provider/verifier, …). ScenarioContext derives its offered capabilities from the environment plus whichever services are actually plugged in, and refuses to run a scenario whose requirements it cannot cover (UnavailableCapability). A testnet scenario can therefore never silently run in ordinary CI.

Scenario runner

The runner (crates/scenario-runner) executes a Scenario through a ScenarioContext and produces a classified ScenarioOutcome. It is the only place a scenario’s operations touch the adapter services; packs, the harness, and the CLI all go through it.

Responsibilities

  • Lifecycle — validate → prepare → initialize → execute → observe → assert → invariants → classify → report → cleanup. The executor records a per-phase timing for each stage on the outcome (spec §67), so reports can decompose time instead of collapsing it.
  • Isolation — the harness that wires the runner builds a fresh simulator per run, so a failed scenario cannot contaminate the next.
  • Timeout & cancellation — a whole-run deadline derived from the configured clock, and a cancellation token the executor checks between operations.
  • Controlled retries — a RetryPolicy that only retries explicitly configured infrastructure-level failures. Deterministic failures never retry and never get hidden.
  • Cleanup — a Cleanup chain that runs after the outcome is fixed; cleanup problems are recorded as observations, never silently dropped.
  • HooksHookChain with before/after phase hooks for instrumentation.
  • Invariant evaluation — the executor runs the scenario’s declared invariants through an InvariantRegistry and records held/violated counts on the outcome.

Classification

The executor never returns “the scenario errored” for a failed expectation. It classifies the run: expected rejections in negative/adversarial packs are successful runs of a failing scenario, distinct from real failures (see scenario-core’s failure model).

Determinism

The runner is clock-driven. The mock harness injects a fixed clock, so every phase timing and outcome is byte-deterministic. Real deployments inject a wall clock; the structure is identical.

Simulator integration

The simulator adapter (crates/adapters/simulator) is the in-repo surface through which scenarios drive state: accounts, balances, commitments, transactions, and snapshots/rollback.

The contract

scenario-core defines provider-neutral service contracts (SimulatorService and friends) in crates/scenario-core/src/context.rs. The adapter implements them. Scenarios, flows, and packs depend only on the contracts — never on a concrete simulator — so the backing system can be swapped without touching scenario code.

The two implementations

The crate ships two SimulatorService implementations, kept deliberately apart so neither borrows the other’s credibility.

RealSimulator — the real integration path

RealSimulator translates scenario operations onto the public lifecycle API of the actual crucible-simulator flow engine, compiled from the simulator repository at a pinned revision (the same pinning discipline prover-adapter uses for crucible-prover):

  • validation, commitment bookkeeping, nullifier accounting, transaction sequencing, and state-root computation all happen inside the simulator crates — this repository re-implements none of it (spec §3);
  • reports carry the engine’s real state root, real event codes, and real error codes; a refused operation is reported as a rejection carrying the engine’s own stable code, never raised as a harness error;
  • the observation channel is a closed public vocabulary — state.root, state.version, table counts, and the operation family. The adapter never reads the simulator’s private ledger, so confidential balances and amounts cannot cross it;
  • the transfer flow runs through the simulator’s own deterministic MockProofProvider. What is real is the pipeline and the state engine, not the cryptography underneath; swapping in the prover-adapter UltraHonk provider is a provider change, not an adapter change.

Reaching a real run is explicit. MockHarness::run_real_simulator wires the real engine with the fixture proof double (isolating the SIMULATE seam), and MockHarness::run_integration wires the real engine and the real crucible-prover service — the only path where one run drives the real simulate and prove layers together.

A real run has a smaller observation vocabulary than the double, because the engine does not publish an invented public balance for a confidential token. Scenarios that decode the double’s vocabulary use MockHarness::run; a real run that needs a confidential value derives it from its own definition data, which is what the privacy pack asserts.

The real engine also enforces semantics the double never modelled, and the adapters’ tests pin them: registration is scoped per token, so an account registered for one token cannot deposit another. That class of divergence is precisely why both paths exist.

InMemorySimulator — the posture double

InMemorySimulator is a fresh-per-run in-memory ledger over the embedded fixture catalog:

  • deterministic operations (register, deposit, merge, confidential transfer, withdraw) with acceptance rules driven by fixture postures (registered, frozen, balances, commitment status),
  • state observations (state.<op>), balance observations (balance.<actor>.<token>), ownership, events, and commitment status,
  • snapshots and rollback.

It is explicitly labeled a test double. Its posture vocabulary (frozen accounts, fixture-declared balances, a scripted replay guard) is fixture semantics a real engine has no reason to model, so it stays for the negative/adversarial packs. Runs over it validate that the scenario layer orchestrates correctly and that expected outcomes computed independently (balance arithmetic, ownership rules) hold — never that the real Confidential Token implementation is correct.

Boundary

This repository never implements token accounting or state-transition logic (spec §3). The double exists only to make the orchestration layer testable hermetically; RealSimulator exists so the same layer can also be pointed at the engine it validates.

Soroban integration

The Soroban adapter (crates/adapters/soroban) isolates everything about running scenarios against Confidential Token contracts rather than the in-memory double. It exists so scenarios can transition from local simulation to contract execution behind one vocabulary (spec §30).

What the adapter provides

  • A contract-surface vocabulary (crates/adapters/soroban/src/contracts.rs) describing the Confidential Token contract functions, their required arguments, and their event codes, so scenario operations map to contract calls deterministically.
  • Operation ↔ call translation (transactions.rs): register, deposit, merge, transfer (with proof reference), and withdraw each translate to a named contract call with the public arguments; the confidential amount stays on the witness side and never appears in a call’s public inputs.
  • Event interpretation (events.rs) mapping contract events to the same public observation keys the simulator reports (ct_register, ct_deposit, …), so assertions written for the double hold on-chain.
  • An isolation gate: without a configured client/contract set, scenarios skip rather than mis-execute (the harness targets Environment::Simulator; a Soroban-targeting run requires explicit capability/environment matching).

Honest status

The adapter is isolated and unit-tested; it is not yet wired to a live contract deployment. It provides the translation and event surface that a deployment client (local Soroban or Testnet) plugs into. Soroban execution is never part of ordinary CI.

Modes

ModeBackingStatus
LOCAL_SIMULATIONin-memory doubleimplemented, default CI
SIMULATOR_PLUS_PROVERdouble + fixture proverimplemented
LOCAL_SOROBANdeployed local contractsadapter ready, client not wired
TESTNETpublic test networkopt-in only (see testnet doc)

Boundary

No contract logic is implemented here. The adapter translates and observes; the contract is the source of truth, and the oracle expectations in scenarios stay independent of both.

Testnet execution (optional, opt-in)

Testnet is never part of ordinary CI (spec §30). It requires an explicitly configured public network, funded accounts, and deployed contracts, so it is always selected by an operator, never by default.

The adapter

crates/adapters/testnet provides the opt-in surface:

  • configuration — validated network settings with an explicit enabled/network gate; without configuration nothing attempts a connection,
  • polling — deterministic poll/backoff logic over a configurable interval, so scenario steps can wait on transaction status without blocking the runner,
  • execution — translation of the scenario’s operation vocabulary into the network’s submission surface (the transport itself is the operator’s wiring).

The adapter is hermetic and unit-tested (no network in tests), exactly like the other adapters.

Running it

# Build + unit-test the adapter, skip network execution (default path):
scripts/run-testnet.sh

# Provide explicit configuration to move past the skip:
export CRUCIBLE_TESTNET_CONFIG='{ "network": "futurenet", ... }'
scripts/run-testnet.sh

CI’s testnet.yml is workflow_dispatch-only: it builds the adapter, runs its hermetic tests, and skips (with a notice) unless a TESTNET_CONFIG secret is present. It can never fail the default PR path.

Scenario posture

Testnet scenarios are tagged testnet, target Environment::Testnet (which is isolated), require explicit capabilities, and are not required by any ordinary CI gate. A scenario that cannot run honestly in the current environment is skipped deliberately — never silently mis-executed.

Troubleshooting

A scenario I expect to pass is failing

  1. Run it alone with the failure detail.
    cargo build -p cli
    target/debug/crucible-scenarios run CT-XXX-001
    target/debug/crucible-scenarios run CT-XXX-001 --json
    
    Expected-failure packs (negative, adversarial) should fail — check the declared outcome matches the observed classification. A FAIL status means the system did not behave as the scenario requires.
  2. Check the invariant counts. Runs attach the full registry; a scenario that passes its assertions but violates conservation/ownership/ binding will fail at the invariants stage. expect-X-holds assertions failing with “not registered” mean the invariant key was misspelled or not attached.
  3. Re-run in parallel. run --parallel and report --parallel must produce byte-identical outcomes to serial. A difference is a genuine determinism bug (shared state, registry mismatch) — file it.
  4. Confirm the fixture posture. Transfers need both parties registered and unfrozen; withdrawals need a confidential balance. The fixtures doc lists what exists.

A declarative document is rejected

crucible-scenarios validate and cargo test -p scenario-format report the exact gate: malformed JSON → wrong format → wrong schema_version → semantic violation (duplicate ids, dangling references, confidential public inputs, zero timeout). Generate a canonical document from a real scenario to see the correct shape, or copy from examples/declarative/.

A fuzz target reports findings

Every finding records its seed and iteration and shrinks to a minimal case. Reproduce deterministically with the recorded seed:

target/debug/crucible-scenarios fuzz --seed <seed> --iterations 1000

Then convert the finding into a permanent CT-REG-* regression scenario (fuzz crate’s to_regression_case) and file it — regression scenarios are never deleted.

Parallelism concerns

run --parallel splits by category and keeps concurrency scenarios serial. If a report differs between serial and parallel modes, that is a defect in the harness or executor (outcomes are re-sorted into registry order), not an acceptable flake — file it under the reproducibility checklist.

Adapter / boundary questions

This repository never implements token accounting or proving logic. If you find yourself tempted to add balance math or proof construction inside a pack, the fix belongs in the simulator/prover adapters or the upstream repositories instead — the PR template enforces this boundary.

Contributing

Thank you for contributing to crucible-scenarios, the STRESS-TEST layer of the Crucible hybrid system.

Repository boundaries — read this first

This repository validates the Crucible stack; it does not define it.

  • Do not implement Confidential Token protocol semantics here. If you need simulation semantics, consume crucible-simulator through crates/adapters/simulator; if you need proofs, consume crucible-prover through crates/adapters/prover.
  • Do not invent fake protocol semantics merely to make a test pass. When an expected result derives from a protocol invariant, document the invariant.
  • Tests validate externally observable behavior. Derive expected results from declared inputs and protocol rules — never by calling the same internal function under test (anti-circular-testing requirement).
  • A single successful return value is never enough for important scenarios: layer oracles (explicit expectation → invariant → independent state comparison → cross-component comparison).
  • Never commit private keys, wallet seeds, private witnesses, or confidential user information. Sensitive values stay out of logs, reports, and fixtures.
  • Mocks are test doubles, not cryptographic proofs. Label them as such.
  • Testnet scenarios are tagged testnet, isolated, and never required by ordinary CI.

What the coupling means for scoping work

Nothing consumes this repository — it is the leaf of the polyrepo — so a change here is fully contained: you may alter any internal contract without coordinating a pin bump anywhere else. The coupling runs one way. This repository pins crucible-simulator and crucible-prover at fixed revisions, recorded and enforced in docs/cross-repo-pinning.md. If your change needs a different revision of either, that bump is its own commit with its own justification, not a side effect of unrelated work — and it is a separate issue, because a pull request that spans repositories cannot be reviewed, tested, or reverted as a unit.

Adding a scenario

  1. Pick a stable scenario ID (e.g. CT-XFER-001) — IDs are permanent.
  2. Declare initial state, operation sequence, expectations, assertions, invariants, timeout, seed, and required capabilities.
  3. Prefer the declarative scenario format (see docs/scenario-format.md) for scenarios that need no custom Rust; use code-defined scenarios for complex cases.
  4. Register the scenario and give it deterministic fixtures and test vectors.
  5. Add unit tests for any new model/utility code and, where possible, a regression record for every discovered bug.
  6. Verify: scripts/validate-scenarios.sh, then the relevant test script (e.g. scripts/test-unit.sh, scripts/test-adversarial.sh).

Contributors can add scenarios without modifying core architecture.

Development workflow

cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace

Commit messages describe the what and the why; each commit is one self-contained improvement. See CHANGELOG.md for the format used.

Issue surface

The repository intentionally provides contributor opportunities across: scenario implementation, negative/adversarial/conformance/invariant/regression/ privacy tests, fuzz targets, performance scenarios, integration adapters, test vectors, fixtures, reporting, CLI, and documentation. Every issue identifies a scenario ID, objective, affected area, expected behavior, implementation requirements, acceptance criteria, and required tests/docs — see the GitHub issue templates in .github/ISSUE_TEMPLATE/.

Code of conduct

All contributors are expected to follow the Code of Conduct.

Security Policy

Reporting a vulnerability

This repository is a testing and validation layer. It never contains production credentials, wallet seeds, private keys, or confidential user data by design — but a defect in scenario, fixture, or reporting code could still leak sensitive material or mis-validate the Crucible stack.

If you believe you have found a security vulnerability in this repository or in anything it validates, please report it privately. Do not open a public issue.

  • Open a private security advisory on GitHub for this repository, or
  • Contact the maintainers through the contact channels listed in CONTRIBUTING.md.

Include, when possible:

  • the affected crate or scenario family and version,
  • a minimal reproduction (seed, scenario ID, fixture, or sequence),
  • the observed versus expected behavior,
  • whether any sensitive material was involved.

Scope

In scope:

  • accidental disclosure of witness/secret material through logs, errors, reports, fixtures, or serialized results,
  • scenario or assertion logic that accepts invalid proofs, stale state, or unauthorized operations,
  • repository-boundary violations (e.g. re-implementing simulator/prover semantics), and
  • redaction or classification failures in observation/reporting code.

Out of scope:

  • Confidential Token protocol design itself (owned by the other Crucible polyrepos), and
  • proofs/circuits/witnesses produced by crucible-prover.

Security expectations

  • No production secrets are ever committed.
  • Private witnesses and confidential amounts never appear in logs or reports.
  • Mocks are never treated as cryptographically valid proofs.
  • Testnet execution is optional, isolated, and never required by ordinary CI.

See CONTRIBUTING.md for the repository boundaries these rules come from; on the confidential-testing model specifically, see docs/privacy-testing.md.

Contributor Covenant Code of Conduct

Our Pledge

We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.

We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.

Our Standards

Examples of behavior that contributes to a positive environment for our community include:

  • Demonstrating empathy and kindness toward other people
  • Being respectful of differing opinions, viewpoints, and experiences
  • Giving and gracefully accepting constructive feedback
  • Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
  • Focusing on what is best not just for us as individuals, but for the overall community

Examples of unacceptable behavior include:

  • The use of sexualized language or imagery, and sexual attention or advances
  • Trolling, insulting or derogatory comments, and personal or political attacks
  • Public or private harassment
  • Publishing others’ private information, such as a physical or email address, without their explicit permission
  • Other conduct which could reasonably be considered inappropriate in a professional setting

Enforcement Responsibilities

Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.

Scope

This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces.

Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement. All complaints will be reviewed and investigated promptly and fairly.

Attribution

This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.

Built from

This site renders documentation that lives in three other repositories. Every page under Simulator, Prover, and Stress-test was copied from the revision listed below at build time and then rendered. Nothing here is edited on this site; to change a page, change the repository that owns it.

Generated by this build.