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:
| Contract | CCS6Z3VVCKV4F5BCH7VXJLKKWMDROUWOTZYROJ4T26CM7R45SE4IFYI2 |
| Network | testnet (Test SDF Network ; September 2015) |
| Verification key | the transfer circuit’s, fixed at deploy |
| Deploy transaction | 2f821d07…ea7e52 — ledger 4569701 |
| Proof verified by it | 5ef50bff…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
| Repository | Role | Consumes |
|---|---|---|
crucible-simulator | SIMULATE — deterministic state and execution model | nothing |
crucible-prover | PROVE — circuits, witnesses, proving, verification | crucible-simulator |
crucible-scenarios | STRESS-TEST — orchestration, conformance, adversarial, privacy, regression, reporting | crucible-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-nightlylocally, bb0.87.0on 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
- How it fits together — architecture
- The verification seam — Soroban verification
- Security guarantees — guarantees and mechanisms, threat model
- The execution model — simulator model, deterministic execution
- What the harness checks — conformance
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
| Beat | Point |
|---|---|
| The problem | Confidential 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 idea | Crucible makes one claim checkable rather than assumed: what a confidential token transaction does locally is what it does on-chain. |
| Architecture | Three layers that compile into each other, with every edge pinned to an immutable revision so a change cannot silently drift underneath another layer. |
| Simulate | Determinism as an enforced property, not a habit — same configuration and seed give the same state, events, commitments and outcomes, bit for bit. |
| Prove | Real 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. |
| Testnet | An UltraHonk verifier contract deployed to Stellar testnet, with proofs verified against it from a checkout that needs no key and no fee. |
| Stress-test | Conformance, adversarial, privacy, regression and performance suites wrapped around the same operations, judged by the real engine rather than a stand-in. |
| Rigour | The 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
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
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.
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:
| Repository | Layer | Responsibility |
|---|---|---|
crucible-simulator | SIMULATE | reproduce Confidential Token flows and state transitions |
crucible-prover | PROVE | generate and verify mock and real cryptographic proofs |
crucible-scenarios | STRESS-TEST | execute conformance, failure, and adversarial scenarios |
crucible-docs | READ | the 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
| Concept | Where | Notes |
|---|---|---|
| Account | core | Synthetic identities (Alice/Bob/…), registration + permission state |
| Token | core | Native test asset and configured confidential tokens |
| Commitment | core/state | Deterministic digest over (owner, token, value, nonce); Active/Consumed lifecycle |
| Balance | core | Observable anchor (commitment) vs private simulation value |
| Operation | core | The five lifecycle operations as one enum |
| Transaction | core | Deterministic-ID record of every operation — Success/Rejected/Failed with the error embedded |
| Event | core | Value-free observable output (success + operation_rejected) |
| StateStore | state | BTree-backed deterministic store with a state-root digest |
| Nullifier | state | Consumed-commitment registry; replay protection |
| Transition | state | Explicit State0 → Transition → State1, audited on record |
| Snapshot | state | Named full-state captures with exact restore |
| ProofProvider | flows | The interface crucible-prover will implement; mock today |
| Simulator | simulator | The 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/determinismcompares full transcripts across runs. Snapshots let you capture state, run operations, restore, and rerun the exact sequence — the debugging workflowcrucible-scenariosrelies 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
- Apache License, Version 2.0 (
LICENSE-APACHE) - MIT license (
LICENSE-MIT)
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 ◄───────────────┘
| Repository | Property | Responsibility |
|---|---|---|
crucible-simulator | Simulate | Reproduce Confidential Token flows and state transitions |
crucible-prover | Prove | Generate and verify mock/real cryptographic proofs |
crucible-scenarios | Stress-Test | Execute 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. corenever references the other crates.- No crate imports a proving system.
ProofProvideris implemented bycrucible-proverand mocked here. - Fixture data contracts are stable;
crucible-scenariosconsumes 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)
| Operation | Success event | Rejection event |
|---|---|---|
| Register | account_registered | operation_rejected |
| Deposit | deposit_completed | operation_rejected |
| Merge | merge_completed | operation_rejected |
| Transfer | confidential_transfer_completed | operation_rejected |
| Withdraw | withdrawal_completed | operation_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
| Area | Path | What lives there |
|---|---|---|
| Domain model | crates/core | accounts, tokens, commitments, balances, operations, transactions, events, errors |
| State engine | crates/state | store, commitment lifecycle, nullifiers, transitions, rollback, snapshots, Merkle |
| Flow engine | crates/flows | the five flows, authorization, validation, ProofProvider boundary |
| High-level API | crates/simulator | Configuration, Environment, Simulator (examples and benches live on the root package) |
| Fixtures | crates/fixtures + fixtures/ + schemas/ | typed fixtures, loader, scenario runner, the corpus, the schemas |
| CLI | cli | the stateful crucible binary |
| Boundaries | crates/adapters/* | Soroban adapter, testnet configuration + runner |
| Root package | examples/, tests/, benches/ | runnable examples, defining-property suites, criterion benchmarks |
Rules of the house
- 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.
- 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. - Atomicity. A failed operation must leave no partial state. Failed
operations are still recorded (as
Rejected/Failedtransactions with anoperation_rejectedevent) — do not regress that to silent errors. - One operation, one ledger. Flows advance at most one ledger step per executed operation.
- Do not raise
sha2past 0.10 on its own. This crate’s digests are a published interface:crucible-proverpins a simulator revision and compiles it, and it also depends onstellar-xdr28, which requiressha20.10. Raisingsha2here 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 incrucible-prover/docs/dependency-policy.md. Any change to the hashed encoding itself must be paired with regenerated known-answer vectors incrates/core/src/hashing.rs, which pin the digests by value.
Great first contributions
- A new failure fixture — every bug you find becomes
fixtures/failures/*.jsonwith anexpected_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>.rsare 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 nondeterminism | Prevention |
|---|---|
| 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 time | Time is a synthetic LedgerContext advanced by a fixed configured step (protocol.step_seconds). Nothing reads the clock. |
| Iteration order | Every state collection is a BTree* map/set, so iteration is sorted. Hash* collections are never used for state. |
| Hash randomization | Hashing is domain-separated SHA-256 over canonical byte/JSON inputs, never DefaultHasher. |
| Serialization drift | Transaction IDs digest canonically serialized payloads (serde_json), so they do not depend on Rust formatting details. |
| PRNG draw order | Flows draw nonces in fixed code order, so the same operation sequence consumes the stream identically. |
| Floating point | All 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 (seedocs/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.
| Code | Category | Retryable | Raised when |
|---|---|---|---|
invalid_operation | validation | no | The operation is malformed or structurally invalid. |
invalid_account | validation | no | A referenced account does not exist. |
invalid_token | validation | no | A referenced token does not exist. |
zero_amount | validation | no | An 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.
| Code | Category | Retryable | Raised when |
|---|---|---|---|
permission_denied | authorization | no | An account transacts on a token it has no permission for. |
unauthorized | authorization | no | The actor is not authorized for this operation. |
wrong_owner | authorization | no | A commitment belongs to a different owner than the one claiming it. |
Registration and accounts
| Code | Category | Retryable | Raised when |
|---|---|---|---|
not_registered | state | no | An account is not registered for the token it is transacting on. |
duplicate_registration | conflict | no | An account registers twice for the same token. |
Value and commitments
The state preconditions that make a confidential transfer sound.
| Code | Category | Retryable | Raised when |
|---|---|---|---|
insufficient_balance | state | no | Available confidential value is less than the requested amount. The message carries both figures. |
unknown_commitment | state | no | A referenced commitment does not exist. |
inactive_commitment | state | no | A commitment is referenced but is not in an active state. |
consumed_commitment | state | no | A commitment was already consumed — a double-spend attempt. Also the code a replayed deposit produces. |
invalid_proof | cryptographic | no | A proof reference is missing, unverifiable, or invalid. |
invalid_state | state | no | The operation would move the system into a state that violates its invariants. |
Internal
| Code | Category | Retryable | Raised when |
|---|---|---|---|
state_corruption | internal | no | A 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
| Code | Category | Retryable | Raised when |
|---|---|---|---|
unknown_snapshot | snapshot | no | A named snapshot does not exist. |
duplicate_snapshot | snapshot | no | A named snapshot already exists. |
Fixtures
| Code | Category | Retryable | Raised when |
|---|---|---|---|
invalid_fixture | fixture | no | A 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.
| Code | Category | Retryable | Raised when |
|---|---|---|---|
missing_amount | adapter | no | The adapter needs an amount but none was supplied. |
bad_arguments | adapter | no | Arguments did not match the call’s expected arity or types. |
unimplemented | adapter | no | The 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.
| Code | Category | Retryable | Raised when |
|---|---|---|---|
io | environment | yes | Reading 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_environment | environment | no | The persisted environment exists but is unreadable or does not parse. |
usage | usage | no | The 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:
| Repository | Property | Responsibility |
|---|---|---|
| crucible-simulator | Simulate | Reproduce Confidential Token flows and state transitions |
| crucible-prover | Prove | Generate and verify mock/real cryptographic proofs |
| crucible-scenarios | Stress-Test | Execute 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(incrates/flows). crucible-proverimplements 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:
- Deterministic outcomes — a scenario is
fixture + environment + seed + operation sequenceand its result is reproducible, so failures are communicated as (fixture, environment, seed, sequence). - Explicit transitions — every operation records
State0 -> Transition -> State1, so a scenario can assert on intermediate and final state. - 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.
Eventcarries no amounts;Transactionrecords 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:
- 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.
- The mock proof provider always proves.
MockProofProviderattests 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. - 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.
- 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:
- 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.
- 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 labeledinspect_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
ProofProviderboundary (seedocs/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:
| Status | Meaning |
|---|---|
Success | Applied and committed: version bumped, transition recorded |
Rejected | Failed validation/authorization/state/proof before any mutation; nothing changed, operation_rejected event emitted |
Failed | Started 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 areduplicate_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_snapshotif 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>;
}
}
AdapterRequestnormalizes 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.AdapterResponsereturns the outcome summary, event names, ledger sequence, and resulting state root — everything an outer layer needs.AdapterErrorcarries 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:
- Build a real Soroban client (
contract invocation → simulation → response parsing → event extraction) using the shapes inclient.rs(ContractInvocation,ContractResult). - Translate contract responses and RPC errors into
AdapterResponse/AdapterErrorusing the same vocabulary the local adapter uses, so scenario expectations do not change. - 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
| Collection | Type key | Notes |
|---|---|---|
| Accounts | AccountId | Registration + permission state, confidential heads |
| Tokens | TokenId | Policy configuration |
| Commitments | CommitmentId | Value + blinding nonce (private simulation state) |
| Statuses | CommitmentId | Active / Consumed |
| Nullifiers | Nullifier | Replay protection; one per consumed commitment |
| Transactions | TransactionId | Deterministic records of applied operations |
| Events | EventId | Value-free observable output |
| Transitions | ordered | The audited transition log |
| Snapshots | name | Full-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_commitmentrequires 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::begincaptures a full-state checkpoint; dropping the scope withoutcommit()restores it exactly — failed operations can never leave partial state, nullifier gaps, or skipped sequence numbers.- Named snapshots capture the whole store;
restore_snapshotreplaces 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)
- Conservation — transfers never create or destroy value.
- Commitment integrity — consumed commitments are never reused; each consumption registers exactly one nullifier; active + consumed == created.
- Registration — unregistered accounts cannot hold or move confidential value on registration-required tokens.
- Atomicity — failed operations leave the state fingerprint bit-identical.
- 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
AdapterRequestshapes 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
| Area | Crate | Notes |
|---|---|---|
| Domain model | crates/core | No engine dependencies |
| State engine | crates/state | Store, commitments, nullifiers, transitions, rollback, snapshots |
| Flows | crates/flows | Five lifecycle flows + validation + ProofProvider |
| High-level API | crates/simulator | Configuration / Environment / Simulator |
| Fixtures & suites | crates/fixtures | Versioned fixtures + the integration/determinism/invariant/regression suites |
Dependency direction is strictly core → state → flows → simulator → fixtures.
Ground rules
- Determinism is sacred. Never introduce system entropy, wall-clock
reads,
Hash*state collections, or float math into simulation logic. Randomness flows only throughDeterministicRng. - Never silently mutate state. Every applied change goes through the
flow pipeline and ends in a recorded, audited
StateTransition. - Failed operations must leave no trace. Apply inside
TransactionScope; on error, state (including sequence counters) must be bit-identical. - Privacy boundary. Observable output (events, published records) never contains amounts or commitment values. Private values are only reachable through explicitly labeled inspection APIs.
- One behavior change, one focused commit, with a message explaining the why. Do not bundle unrelated improvements.
- 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 underfixtures/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-targetswarning-free -
cargo fmtclean - 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 withStateCorruption), rollback scopes, and the invariant suite. - Commitment reuse / replay — spending the same commitment twice.
Countermeasures:
Consumedstatus + nullifier registry; replaying a consumed deposit fails withConsumedCommitment. 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.
TransactionScoperestores 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:
- Do not open a public issue for exploitable findings.
- Open a private report, or email the maintainers with full repro: fixture file (or environment + seed + operation sequence) and the observed vs expected behavior.
- 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
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
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.
| Repository | Layer | Responsibility |
|---|---|---|
crucible-simulator | SIMULATE | deterministic state and execution model |
crucible-prover | PROVE | zero-knowledge proof generation, circuits, verification, proof artifacts |
crucible-scenarios | STRESS-TEST | scenario orchestration and adversarial testing |
crucible-docs | READ | the rendered documentation for all three layers — a build of their markdown, not a source of it |
The boundary is strict:
crucible-simulatorowns the state and execution model.crucible-proverowns proving.crucible-scenariosowns 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
CCS6Z3VVCKV4F5BCH7VXJLKKWMDROUWOTZYROJ4T26CM7R45SE4IFYI2on 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 livesubmits 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.mdanddocs/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 & lifecycle — docs/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 & security — docs/privacy.md (structural
secret handling), docs/security.md (guarantees and
mechanisms), docs/threat-model.md (adversaries and
defenses).
Circuits & backends — docs/circuit-model.md
(operation circuits, boundaries, measured costs), docs/noir.md
(Noir toolchain split), docs/ultrahonk.md (real
UltraHonk proving with bb).
Ops & tooling — docs/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 & roadmap — docs/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-simulatorowns the state and execution model.crucible-proverowns proving.crucible-scenariosowns 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:
- the manifest must parse and its paths must stay inside the artifact directory (no traversal);
- every declared file must exist and match its SHA-256 byte-for-byte;
- 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
checkruns the same strict loader the provider runs and exits non-zero listing every problem.generatecopies<circuits>/target/<op>.jsoninto 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 gate —
artifacts generateinto a temp root, thendiff -ragainst the committedartifacts/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.rssuite — 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 thecrucible-artifactsunit 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 contractCCS6Z3VVCKV4F5BCH7VXJLKKWMDROUWOTZYROJ4T26CM7R45SE4IFYI2. - 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):
| Category | Count |
|---|---|
| Vulnerabilities | 0 |
| Unmaintained | 0 |
| Unsound | 0 |
| Yanked | 0 |
| Informational | 0 |
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):
| Invariant | Result |
|---|---|
| Legit transfer fixture proof verifies on-chain | ✅ Ok(()) |
| Tampered proof (1 byte flipped) rejected | ✅ Error(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
tempfilescratch 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)
- Mainnet deployment requires a funded mainnet account and a key-management review; testnet is fully covered.
- Toolchain divergence: the audited verifier accepts bb
v0.87.0proofs; the local backend emits bb6.0.0-nightlyproofs. 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 indocs/soroban-verification.md. - 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_commitmentopens to(old_amount, old_blinding);amountin 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_amountfor the sender address;amount <= old_amount(no overdraw);amountin range. - returns: recipient commitment over
amountbound to the recipient address, change commitment overold_amount - amountbound 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
- Ownership — only the account secret passes the address assertion.
- No overdraw —
spend <= balance(bounded u64 comparison). - Range — every amount is constrained to 63 bits before arithmetic, so field wraps cannot mint value.
- Opening consistency — a consumed commitment must open to the witness provided; the ledger-facing commitment is bound to the transition.
- 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.
- Conservation — value in the produced commitments equals value out of the consumed ones (commitments are computed, not chosen).
- 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:
| Package | ACIR | Brillig | Notes |
|---|---|---|---|
register | 10 | 44 | ownership only |
deposit | 96 | 61 | consume + produce + range |
merge | 124 | 61 | two consumes + produce |
transfer | 118 | 61 | consume + two produces + overdraw |
withdraw | 100 | 61 | consume + produce + overdraw |
gadget_commitment | 18 | 44 | one Pedersen commitment |
gadget_hash | 18 | 44 | one two-field hash |
gadget_range | 16 | 17 | 63-bit amount range check |
gadget_ownership | 11 | 44 | address ownership assertion |
gadget_state | 62 | 44 | full 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. Requiresnargoandbbon PATH (seescripts/check-bb.sh) and compiled bytecode undercircuits/target/(runcircuits compilefirst).--outdefaults to<vector-id>.<backend>.proof.jsonin 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:
| Flag | Default |
|---|---|
--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
| Code | Meaning |
|---|---|
| 0 | success (or verify: proof accepted) |
| 1 | runtime error — including verification rejections and failed checks |
| 2 | argument/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
| Surface | Version carrier | Policy |
|---|---|---|
| Proof envelope | integer version (current 1) | parsing rejects versions newer than known; never guess |
| Artifact manifest | manifest_version (current 1) + artifact_version | loader rejects unknown manifest schemas; artifact_version bumps on recompile without circuit change |
| Circuit | circuit_version (0.1.0) | a circuit change bumps it; backends gate on it |
| Backend | BackendId string (mock, ultrahonk) | open string set so new architectures (RISC Zero/Groth16) need no interface change |
| Proof encoding | ProofFormat tag (mock-envelope-v1, ultrahonk-v1) | names the encoding; bytes stay backend-owned |
| Verification key | VerificationKeyId | derived from circuit + version + artifact checksum |
| Toolchain pairing | BACKEND_COMPAT matrix | (nargo, bb, circuit_version) rows validated in CI |
Rules that keep surfaces compatible
- 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). - Identity travels with the proof. A
ProofEnvelopenames circuit, versions, backend, vk id, artifact checksum, and state reference, so a consumer never guesses what produced a proof (docs/proof-format.md). - 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 andBACKEND_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). - 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):
| Metric | Value |
|---|---|
| Line coverage | 87.5% |
| Region coverage | 88.9% |
| Function coverage | 77.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 circuitsjob executes the live UltraHonk round trips (oracle → witness →bb prove→bb verify), so the 87% figure includes proofs that actually verify, not mocks. - Error paths are first-class — every error variant in
docs/error-surface.mdis 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 behindCRUCIBLE_SOROBAN_LIVE=1; the CLI sits in the 65–95% band because its hard paths (realbbproving) 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:
- the happy path (one round trip),
- each structural rejection the feature can produce (one per error variant),
- 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
- 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.
- 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:
| Repository | Vectors | Pins |
|---|---|---|
crucible-prover | crucible-interfaces (proof_provider::proof) | ArtifactChecksum::from_bytes over fixed byte strings |
crucible-prover | crucible-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-simulator | crucible-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:
- The gate — fmt, clippy, schema validation, and the full workspace
test suite must pass, then a release build (
cargo build --release). - The artifact — the
crucible-proverCLI 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 artifacts —
artifacts/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 byprove/verify). Keys are derived bybbduring 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:
| Contract | CCS6Z3VVCKV4F5BCH7VXJLKKWMDROUWOTZYROJ4T26CM7R45SE4IFYI2 |
| Network | testnet (Test SDF Network ; September 2015) |
| Verification key | the transfer circuit’s, fixed at deploy |
| Deploy tx | 2f821d072a241410ad92d29d14eb71a03dc4726b02df35ce73e94a8621ea7e52 (ledger 4569701) |
| Verified-proof tx | 5ef50bffa8a89914a987c1d06b10fa096a31db72af61d861fd751cdaad653e79 (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 bb6.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).
Related
- docs/reproducibility.md — the pins that make a release reproducible.
- docs/compatibility.md — why a release is a bundle of agreeing versions, not a single binary.
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 with failure modes — every variant corresponds to a code path that actually produces it, and every code path has a test.
- 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.
- No reserved codes —
Interal-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. - 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:
| Code | Name | Raised when |
|---|---|---|
| 1 | VkInvalidLength | VK byte slice has the wrong length |
| 2 | VkInvalidParameters | VK header has out-of-range structural parameters |
| 3 | ProofParseError | Proof byte slice has the wrong length (not 456 words) |
| 4 | VerificationFailed | Cryptographic verification rejected the proof |
| 5 | VkNotSet | verify_proof called before the VK was initialized |
| 6 | AlreadyInitialized | __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:
| Reason | Meaning |
|---|---|
InvalidProof | Proof bytes did not verify under the given key |
PublicOutputMismatch | Public outputs differ from what the proof commits to |
StateReferenceMismatch | Proof is bound to a different state root (stale/replay) |
WrongVerificationKey | Proof was produced under a different VK (id unresolvable) |
CircuitMismatch | Proof is for a different circuit |
VersionMismatch | Proof is for a different circuit version |
ArtifactChecksumMismatch | Artifact that produced the proof differs from the pinned one |
BackendMismatch | Proof format does not match the verifier |
MissingStateBinding | Proof 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:
| Crate | Variants |
|---|---|
crucible-artifacts | MalformedManifest, UnsupportedManifestVersion, MissingFile, UnexpectedFile, ChecksumMismatch, IntegrityMismatch, UnsafePath, ReadFailure (8) |
crucible-noir | BinaryNotFound, UnsupportedVersion, VersionParse, CommandFailed, ExpectedOutput, Io, MalformedArtifact (7) |
crucible-ultrahonk | Encode, Truncated, BadVerificationKeyId, UnsupportedVersion, BinaryNotFound, UnsupportedBbVersion, VersionParse, MissingFile, Io, Spawn, CommandFailed, MalformedArtifact, InconsistentArtifacts (13) |
crucible-witness | MissingRequired, Overlap, OperationMismatch, InvalidValue, Io, Encoding (6 + 2 side markers) |
crucible-verifier | UnknownBackend, VerifierFailed, Internal (3) |
crucible-prover-core | UnknownBackend, UnsupportedCircuit, InvalidRequest, Generation, Envelope, NotVerified, NoVerifier, Internal (8) |
crucible-proof-types | UnsupportedVersion, Encoding (2) |
Interface-layer errors (interfaces crate)
| Type | Variants |
|---|---|
ProviderError | InvalidRequest, UnsupportedCircuit, ArtifactUnavailable, ArtifactIntegrity, BackendUnavailable, ProofGeneration, Internal (7) |
VerifierError | UnsupportedVerifier, InvalidRequest, VerificationUnavailable, Internal (4) |
ProverError | NoProviderAvailable, Provider, VerificationFailed, NotVerified (4) |
WitnessError | MissingWitness, MissingPublicInputs, MissingStateReference, StateRootMismatch (4) |
Soroban adapter errors
| Type | Variants |
|---|---|
ContractError | BackendUnavailable, VkStore, VerificationRun (3) |
LiveError | InvalidContractAddress, InvalidSourceAccount, Rpc, MalformedResponse, Encode (5) |
PayloadError | Malformed, 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).
| Call | Outcome | minResourceFee | Fee actually charged | Host instructions |
|---|---|---|---|---|
verify_proof — valid transfer proof | ✅ verified | 149,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 | ❌ rejected | 0 (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:
| Resource | Measured | Notes |
|---|---|---|
| Instructions | 95.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 read | 0 B | Verification is read-only. |
| Write | 0 B | The verifier writes nothing. |
| Ledger keys | 2 read | The 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_costreturns the RPC’sminResourceFeeand 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
SorobanPayloadversion/count prefix exists only for local round-tripping and never reaches the contract. - One op, no auth. A verification is a single
InvokeHostFunctionoperation 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.0byte 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.
Related
- docs/soroban-verification.md — the deployed contract, the on-chain proof-format pin, and the live test setup.
- docs/performance.md — local (proving-side) performance.
- docs/verification.md — the verifier service model.
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:
| Stage | Tool | Produces |
|---|---|---|
| Compile | nargo compile | ACIR circuit artifacts (target/<pkg>.json) |
| Execute | nargo execute | a solved witness from Prover.toml inputs |
| Metrics | nargo info | per-function ACIR/Brillig opcode counts |
| Unit tests | nargo test | in-circuit #[test] execution |
| Prove | bb prove (Barretenberg) | the actual ZK proof |
| Verify | bb 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-noirends at artifacts + witnesses (compile, execute, info, artifact parsing, version checks). It never shells out to a prover.- Real UltraHonk proving belongs to the Barretenberg
bbbackend, executed throughcrucible-ultrahonk(BbToolchain+prove/verifyinexec); seedocs/ultrahonk.mdfor the validatednargo×bbpairing, 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.gitignoredeliberately — 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:
| Stage | Tool | Measured by |
|---|---|---|
| Constraint count / circuit size | nargo (compile-time) | nargo info per package |
| Witness solving | nargo execute | live runs, CI |
| UltraHonk proving | bb prove | benchmark --backend ultrahonk, live suites |
| UltraHonk verification | bb verify | same |
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
nargo info(constraint costs) — every operation circuit and every measurement gadget undercircuits/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).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.crucible-prover benchmark <op>— per-phase timings (prove, verify, serialize) plus proof/envelope byte sizes over--iterations. With--backend mockit prints an explicit TEST ONLY warning; with--backend ultrahonkit times real proving throughbb.- Live suites —
cargo test -p crucible-tests --test ultrahonk --test real_backendprove 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
performanceissue 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
| Mechanism | Where |
|---|---|
SecretValue implements no Debug, Display, or Serialize | interfaces/src/circuit |
ProofRequest (which carries secrets) has no Serialize; only a redacted JSON view exists | interfaces/src/proof_provider/request.rs |
Debug on requests/witnesses prints names and counts, never values | interfaces, crucible-witness |
The witness encoder is the single place private values leave memory; writes Prover.toml at 0600 | crates/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 only | crates/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/requestDebugand redacted JSON never contain secret values (tests/tests/security/witness_leakage.rs, unit tests ininterfaces).- 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 sameSecretValuepath 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 > 1fails withUnsupportedVersion): 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).
Related
- docs/public-inputs.md — what the envelope’s public outputs mean.
- docs/verification.md — how an envelope is turned back into a verification request.
- docs/compatibility.md — versioning policy across the envelope, manifests, and toolchains.
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:
| Reason | Meaning |
|---|---|
InvalidProof | proof bytes failed (tampered, corrupted, wrong key) |
PublicOutputMismatch | outputs differ from what the proof commits to |
StateReferenceMismatch | bound to different state (stale/replay) |
WrongVerificationKey | produced under a different key |
CircuitMismatch / VersionMismatch | wrong circuit or version |
ArtifactChecksumMismatch | artifact was tampered or replaced |
BackendMismatch | format does not match this verifier |
MissingStateBinding | binding 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
- Structural (
ProofRequest::validate): every operation requires a private witness and public inputs; state-bound operations (merge, transfer, withdraw) require a state reference. - Circuit-level (
prover-core::witness): the operation’s required private names must be present. The list is kept in lockstep with the circuits’mainparameters — e.g.transferneedssender_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. - 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:
| Role | Type | Direction |
|---|---|---|
| Inputs a request commits to | PublicInputBag | request → witness → circuit |
| Outputs the circuit reports | OutputBag | circuit → 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
| Pin | Where | Enforced by |
|---|---|---|
| Rust toolchain 1.98 | rust-toolchain.toml | rustup |
nargo 1.0.0-beta.26 | TESTED_NARGO_VERSION in crates/noir/src/lib.rs, scripts/setup-noir.sh, CI | version gate in crucible-noir, CI install |
bb 6.0.0-nightly.20260903 | TESTED_BB_VERSION in crates/ultrahonk/src/lib.rs, CI | version gate in crucible-ultrahonk, CI install |
| nargo × bb pairing + circuit versions | BACKEND_COMPAT in crates/ultrahonk/src/backend.rs | provider supports/check_supported |
| Canonical value encoding | FieldValue/SecretValue canonical lowercase hex (no 0x, no leading zeros) | constructors reject non-canonical forms |
| Envelope format v1 + deterministic JSON | crates/proof-types | serialization is field-ordered; parsing rejects future versions |
Deterministic artifacts
Compiled bytecode is pinned with byte-for-byte reproducibility:
- Artifacts are generated by a deterministic path: per-package
nargo execute(seescripts/generate-test-vectors.sh) — a whole-workspacenargo compileproduces different debug-metadata ordering, so the generate path matters as much as the compiler version. crucible-prover artifacts generatewrites bytecode + a canonicalmanifest.json(sorted file list, ordered JSON); identical bytecode reproduces byte-identical manifests.- CI runs the fresh-compile determinism gate: generate to a fresh
root and
diff -ragainst the committedartifacts/circuits/— a circuit change that forgets to re-pin, or a toolchain drift that changes bytecode, fails CI. - 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 executeruns, 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.shreproduces 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
| Material | Command |
|---|---|
| Circuit vectors | bash scripts/generate-test-vectors.sh |
| Pinned artifacts | cargo run -q -p crucible-cli -- artifacts generate |
| Proof fixtures | bash 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:
SecretValueimplements noDebug,Display, orSerialize. It cannot be formatted, logged, or JSON-encoded; the only escape hatch is the explicit, consumingSecretValue::into_hex()used by the encoder.ProofRequestcarries secrets, so it has noSerialize. Its only JSON path isredacted(), which emits names and counts, never values.Debugviews 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.tomlwith 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
- Never add
Debug,Display, orSerializeto a type that can hold private witness values. - Never include a witness value in an error message, log line, panic, or test fixture. Error messages carry names and identifiers only.
- Never echo toolchain stderr verbatim into errors.
- Never commit
Prover.toml, generated proofs, or generated artifacts with real secrets (.gitignorecovers generated paths; committed fixtures live undertest-vectors/and contain no real secrecy). - 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-simulatorowns the state and execution model.crucible-proverowns 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 (noSerialize, 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’sProofRequest. 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 pinnednargothat returnskey_hash(secret)andcommit(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_lohalves 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:
- through the mock backend — fast, toolchain-free coverage of the whole wiring; and
- through the real UltraHonk backend — the same simulator lifecycle
produces a real proof with
bbthat 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 onnargo+bbon PATH (CI installs both in thenoir circuitsjob) and each run pays for real proving.
Honesty contract
- Mock backend by default. [
ProverBackedProvider::mock] wires the prover’s deterministicMockProver/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 withbbthroughcrucible-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;
FieldValuerejects 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:
- 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.
- 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:
| Toolchain | Proof | VK | Verifies on the contract? |
|---|---|---|---|
bb 0.87.0 + nargo 1.0.0-beta.9 | 456 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 keccak | 262 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 (viaCalldataEncoder, 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 theverify_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 theOk(())value (results[0].xdr=ScVal::Void) or aHostErrorcarrying the contract’sVerificationFailedcode. Nothing is submitted, no fee is paid, no secret is needed — one JSON-RPC call per verification.SorobanVerifier— a [Verifier] implementation that encodes aVerificationRequestand routes it through the contract boundary, socrucible-verifier’sVerificationServicecan 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 verifyagainst 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
- Mainnet deployment — the same contract deployed with a mainnet-funded account and an audited key-management story; out of scope for the testnet milestone.
- 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.
- Toolchain convergence — the project’s local backend (bb
6.0.0-nightly.20260903) and the on-chain pin (bb0.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.
Related
- docs/gas.md — measured per-transaction on-chain fees and the cost breakdown.
- docs/verification.md — the verifier trait, the cross-verifier service, and the failure taxonomy.
- docs/proof-format.md — the envelope that feeds verification.
- docs/ultrahonk.md — the local UltraHonk backend and compatibility matrix.
- docs/deployment.md — network/account hygiene.
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.category—valid, or a reject category such aswrong-owner,insufficient-balance,invalid(opening mismatch),stale-state,malformed-proof,replay. The category is the semantic contract: awrong-ownervector 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 (no0x, no leading zeros), the same formatFieldValue/SecretValueenforce. Private values are the circuit’s privatemainparameters (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 realnargo executeruns, so a fixture that drifts from the circuits fails loudly.state_reference— the state context the proof binds to (nullfor operations that are not state-bound).expect_verification—trueforvalid,falsefor 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:
- Mock tier (always runs): every vector must produce a structurally
valid
ProofRequest;validvectors 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. - Circuit tier (runs when
nargois onPATH): each vector’s witness is written as aProver.tomland executed against the real Noir circuit package.validvectors 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:
scripts/generate-test-vectors.shruns each committed circuitProver.tomland fails if any no longer solves (drift canary).- The JSON
expected_public_outputsin the catalog are copied from realnargo executeoutput (theCircuit 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.rssubmits 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 onCRUCIBLE_SOROBAN_LIVE=1so 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
- Soroban verifier integration (adapter + agreement tests) — docs/soroban-verification.md.
- Simulator adapter, so testnet scenarios run real state transitions — docs/simulator-integration.md.
- 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
| Asset | Sensitivity | Where it lives |
|---|---|---|
| Private witness values | HIGH — the privacy boundary itself | in-memory only, Prover.toml during proving |
| Verification keys | HIGH — forge proofs if leaked | artifact store, verified by checksum |
| Proving keys / secret backend material | CRITICAL — forge proofs if leaked | backend infrastructure, never in this repo |
| Compiled circuit artifacts | MEDIUM — must be authentic, not secret | artifacts/, integrity-checked |
| Proofs | LOW per-proof, but must be authentic & context-bound | envelopes, stored/exchanged |
| Public inputs / state roots | PUBLIC | everywhere, 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)
| Component | Version | Role |
|---|---|---|
nargo | 1.0.0-beta.26 | compile circuits, solve witnesses |
bb | 6.0.0-nightly.20260903 | UltraHonk 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:bbupnormally resolves the rightbbfor yournargoautomatically, 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.shreports 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:
| Stage | Tool | Produces |
|---|---|---|
| Compile | nargo compile | ACIR bytecode JSON |
| Execute | nargo execute | solved witness (.gz) from Prover.toml |
| Prove | bb prove | UltraHonk proof + verification key |
| Verify | bb verify | accept/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 carriesscheme,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:pubparameters first, then returned values. bb verifyexits 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.rs—BbToolchain: locatingbb(BB_BINoverride), parsingbb --version, major-version floor (pre-2026 CLI generations are rejected).crates/ultrahonk/src/exec.rs—prove()/verify(): process execution, JSON artifact parsing, provenance validation (scheme must beultra_honk,bb_versionmust 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 thevk.jsona proof was produced with and verifiers resolve it by id underuhk/<circuit>/<version>/<artifact-sha>.crates/ultrahonk/src/provider.rs—UltraHonkProviderimplementing [ProofProvider]: request bags →Prover.toml(witness encoder, 0600, in a scratch package copy) →nargo execute→bb prove→ a [ProofResponse] whose public outputs are named from the pinned circuit surface.crates/ultrahonk/src/verifier.rs—UltraHonkVerifierimplementing [Verifier]: resolves the key by id, re-encodes the submitted public outputs, and letsbb verifydecide; 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/transfercircuit packages; bb prove→bb verifyround 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:
- Structural — before
bbruns, the submitted state reference must agree with theroot_hi/root_lowords the proof committed to; a stale submission is rejected withStateReferenceMismatch, one stripped of its binding withMissingStateBinding. - Cryptographic — if the submitter rewrites both the state reference
and the root words to root B,
bbrejects: 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_targetselection) 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 realbb verifyover the submitted context, resolving the verification key from theVkStoreby 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.
Related
- docs/proof-lifecycle.md — every stage a proof passes through and its failure modes.
- docs/proof-format.md — the envelope that feeds verification.
- docs/security.md and docs/threat-model.md — the guarantees and adversaries these checks implement.
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:
PrivateWitnessBagholdsSecretValues.SecretValueimplements noDebug, noDisplay, noSerialize— it cannot be formatted, logged, or serialized by accident. The bag exposes names and counts only.PublicInputBagholdsFieldValues (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] —WitnessAssemblermerges public and private bags into oneWitnessData, 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:: expectationspins the per-operation public surface). - [
validation] — structural rules every witness must satisfy before it can be encoded. - [
decoder] — parses circuit public outputs back into anOutputBag. 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:
- Nothing else in the workspace prints a secret value:
WitnessDataand both bags implementDebugas redacted views, error messages carry paths and counts, and toolchain stderr is never echoed into errors (compiler diagnostics can embed source snippets). 0x-prefixing is not cosmetic: Noir’s witness parser treats a bare string as decimal, so an unprefixedabfails to parse and a bare1234silently 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
cargo fmt --all -- --checkcargo clippy --workspace --all-targets -- -D warningscargo test --workspace- If you touched circuits:
scripts/check-circuits.sh - 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
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
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.
CRUCIBLE
│
┌───────────┼───────────┐
▼ ▼ ▼
SIMULATE PROVE STRESS-TEST
crucible- crucible- crucible-
simulator prover scenarios
│ │ │
└───────────┼───────────┘
▼
Confidential Token
validation
Crucible is three source repositories and one documentation site:
| Repository | Layer | Responsibility |
|---|---|---|
crucible-simulator | SIMULATE | deterministic simulation and state execution |
crucible-prover | PROVE | circuits, witnesses, proving, verification, proof artifacts |
crucible-scenarios | STRESS-TEST | scenario orchestration, conformance, adversarial, invariant, privacy, regression, fuzzing, stress testing, reporting |
crucible-docs | READ | the 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 behindscenario-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-simulatorflow 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-proverservice 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.
- the simulator surface is wired to the real
- 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 packs —
happy-path(6 flows),negative(11),conformance(5, stated against the in-memory double),conformance-real(5, stated against and judged by the realcrucible-simulatorengine),adversarial(4),privacy(4),concurrency(4),regression(3),compatibility(3), andperformance(3) — 48 scenarios across ten packs, all runnable through the CLI and gated per family in CI (Level-2scenarios.ymlplus 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-corerejects 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
privacyinvariant 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 inexamples/declarative/, schemas inschemas/. - Parallel execution —
run --parallel/report --parallelrun 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, binarycrucible-scenarios) —list,run,validate,inspect,report(--format text|json|junit|markdown),vectors, andfuzz, plus--paralleland--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
simulatoradapter is an in-repo test double; the prover adapter is wired to the realcrucible-proverservice (seedocs/prover-integration.md). Wiring the realcrucible-simulatorledger 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::MockHarnesspath) 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 actualcrucible-simulatorflow 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_realagainst 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/simulatorandcrates/adapters/proverdefine the provider-neutral surfaces through which scenarios consumecrucible-simulatorandcrucible-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) andcrates/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
- Apache License, Version 2.0 (
LICENSE-APACHE) - MIT license (
LICENSE-MIT)
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
| Polyrepo | Layer | Owns |
|---|---|---|
crucible-simulator | SIMULATE | deterministic simulation and state execution |
crucible-prover | PROVE | circuits, witnesses, proving, verification, proof artifacts, proof interfaces |
crucible-scenarios | STRESS-TEST | scenario 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:
- explicit expected result,
- protocol invariant,
- independent state comparison,
- cross-component comparison.
A single successful return value is never sufficient evidence for an important scenario.
See also
- scenario-model.md — the domain vocabulary.
- execution-model.md — lifecycle and reproducibility.
- scenario-runner.md — how scenarios execute.
- scenario-format.md — declarative documents and schemas.
- fixtures.md, assertions.md — data and checks.
- Suite docs: happy-paths.md, negative-testing.md, adversarial-testing.md, conformance.md, invariants.md, proof-testing.md, compatibility.md, performance.md, privacy-testing.md, concurrency.md, regression.md, fuzzing.md, agent-scenarios.md.
- Integration: simulator-integration.md, prover-integration.md, soroban-integration.md, testnet.md.
- reproducibility.md, cross-repo-pinning.md, troubleshooting.md, contributing.md.
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.
- Balance —
balance.<actor>.<token>equals an independently expected public value. - Ownership —
ownership.<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 classifiedwrong-circuitwhen 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
- No lost updates — two spends from the same account compose: the second spend must see the ledger after the first.
- No interference — transfers on different accounts must not clobber each other’s state; each moves exactly its declared amount.
- Commitment bookkeeping — interleaved operations (merge then transfer) keep commitment statuses reachable from the fixture posture.
- 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):
| Id | What it pins |
|---|---|
| CT-CONC-001 | alice spends 200 to bob then the remaining 100 to issuer; no state is lost between the two spends |
| CT-CONC-002 | alice→bob and bob→issuer compose without interference |
| CT-CONC-003 | a merge followed by a transfer keeps commitment bookkeeping consistent |
| CT-CONC-004 | two 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.
| Pack | IDs | Judged by | Asserts |
|---|---|---|---|
conformance | CT-CONF-001..005 | the in-memory double + fixture prover | the double’s vocabulary: public balances, fixture commitment ids, ct_* event codes |
conformance-real | CT-CONF-R01..R05 | the real crucible-simulator flow engine | only 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:
| ID | Contract | Expected from the sequence |
|---|---|---|
| R01 | a 40-unit deposit | exactly 1 live commitment, 2 accounts, 1 token, 2 transactions |
| R02 | two deposits then a merge | the two commitments consolidate to exactly 1 |
| R03 | a funded 30-unit confidential transfer | the spent commitment is consumed and two are created (live: 1 → 2), and no per-holder public balance is published |
| R04 | a 30-unit withdrawal | completes under the engine’s withdrawal_completed event |
| R05 | the full lifecycle | all 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):
- No second state engine. Balance math, commitment transitions, and
token accounting belong in
crucible-simulatoror its adapter — never in a scenario, pack, or flow. - No second proof engine. Witness construction, proving, and
verification belong in
crucible-proveror its adapter. - No invented protocol semantics. Assert only guarantees the underlying implementation defines. Mocked proofs stay labeled mocked.
- 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).
- No private data. Witnesses, keys, seeds, and confidential amounts never reach logs, reports, observations, or fixtures.
Starting points
- Read
docs/architecture.mdand 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.mdentry 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
| Repository | Role | Consumes |
|---|---|---|
crucible-simulator | SIMULATE — deterministic state and execution model | nothing |
crucible-prover | PROVE — circuits, witnesses, proving, verification | crucible-simulator (proof seam) |
crucible-scenarios | STRESS-TEST — orchestration, conformance, adversarial, privacy, regression, reporting | crucible-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.
| Consumer | Dependency | Pinned revision |
|---|---|---|
crucible-scenarios | crucible-prover | 983ed400ddd3ba9c0ddad1ab6a41e471c6ab3c8c |
crucible-scenarios | crucible-simulator | 7fe82b7c8fc7e2f7bece6a6af36016d8229b3fc3 |
crucible-prover | crucible-simulator | 7fe82b7c8fc7e2f7bece6a6af36016d8229b3fc3 |
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:
- every
rev = "<sha>"pin in this workspace appears in the table above, and - every revision the table attributes to a
crucible-simulatordependency is the same revision this workspace pins, so a simulator bump here cannot land while thecrucible-proverrow 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
- Change the revision in the manifest that owns it. Here that is
crates/adapters/simulator/Cargo.tomlandcrates/adapters/prover/Cargo.toml; incrucible-proverthe simulator crates resolve through[workspace.dependencies]in the root manifest, so a bump there is a single edit. - Run the consuming repository’s own test suite. Its CI compiles the pinned revision, so the consuming repository is what validates the combination.
- 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 item | Status | Evidence |
|---|---|---|---|
| 1 | Scenarios can be registered | ✅ | scenario-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) |
| 2 | Scenarios can be discovered | ✅ | registry + list with filters |
| 3 | Scenarios can be filtered | ✅ | list/run --category/--tag/--pack |
| 4 | Scenarios execute deterministically | ✅ | fixed-clock harness; byte-stable outcomes; parallel ≡ serial (tested) |
| 5 | Simulator integration works | ✅ | RealSimulator 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 |
| 6 | Prover integration works | ✅ | prover 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 |
| 7 | Proof verification can be asserted | ✅ | proof-valid/invalid assertions; verification classification in packs |
| 8 | State transitions can be asserted | ✅ | balance/ownership/commitment/state assertions + invariants |
| 9 | Happy paths exist for supported operations | ✅ | flows catalog: register, deposit, merge, transfer, withdraw, lifecycle (CT-HAPPY-*) |
| 10 | Negative paths exist | ✅ | CT-NEG-001..011 |
| 11 | Adversarial paths exist | ✅ | CT-ADV-001..004 |
| 12 | Replay scenarios exist | ✅ | CT-ADV-003, CT-NEG-008, CT-REG-003 |
| 13 | Stale-state scenarios exist | ✅ | CT-ADV-003, CT-NEG-002 family |
| 14 | Public-input mutation scenarios exist | ✅ | CT-ADV-002, CT-ADV-004 |
| 15 | Proof-tampering scenarios exist | ✅ | CT-ADV-001, negative invalid/tampered/malformed |
| 16 | Privacy tests exist | ✅ | CT-PRIV-001..004 + definition-time guard + privacy invariant |
| 17 | Invariants exist | ✅ | seven built-in checks, attached across packs |
| 18 | Conformance tests exist | ✅ | CT-CONF-001..005 (against doubles; see note) |
| 19 | Regression tests exist | ✅ | CT-REG-001..003 from real bugs; never deleted |
| 20 | Fuzz targets exist | ✅ | four targets, seeded, finding→reduction→CT-REG pipeline |
| 21 | Concurrency scenarios exist | ✅ | CT-CONC-001..004 (sequential composition) + --parallel runner |
| 22 | Test vectors exist | ✅ | CT-VEC-001..024 corpus, validated for coherence |
| 23 | Results are machine-readable | ✅ | JSON, JUnit XML, Markdown; CI-usable exit codes |
| 24 | Failures are reproducible | ✅ | seed + environment on every outcome; deterministic replay |
| 25 | Private data is not leaked | ✅ | redaction by construction; privacy pack + invariant + reporter audit |
| 26 | Mock tests clearly distinguished from real crypto tests | ✅ | doubles labeled everywhere; docs/PR template enforce |
| 27 | Soroban integration isolated behind adapters | ✅ | adapters/soroban (contract surface, translation, events; client not wired) |
| 28 | Testnet integration is optional | ✅ | adapters/testnet opt-in; never required by CI |
| 29 | CI covers appropriate scenario classes | ✅ | Level-1/2 + conformance/adversarial/regression/performance/security/testnet/release |
| 30 | Documentation explains the architecture | ✅ | 27 docs + README status maps |
| 31 | Contributors have clear issue surfaces | ✅ | 8 issue templates + PR template + chooser |
| 32 | No simulator implementation duplicated | ✅ | boundary enforced; no state engine in scenarios |
| 33 | No prover implementation duplicated | ✅ | boundary enforced; no proving in scenarios |
| 34 | No unsupported protocol semantics invented | ✅ | agent 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 realcrucible-simulatorflow 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, viaMockHarness::run_real_simulator, andcrucible-scenarios reportnow 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
matchstatements 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.
| Code | Retryable | Raised when |
|---|---|---|
INVALID_ID | no | A scenario id is malformed or does not match the required shape. |
DUPLICATE_ID | no | A scenario id is registered twice. |
INVALID_METADATA | no | Scenario metadata is missing a required field or is inconsistent. |
UNKNOWN_ACTOR | no | A step references an actor the scenario never declares. |
UNKNOWN_TOKEN | no | A step references a token the scenario never declares. |
UNKNOWN_STATE | no | A step references a named state that was never captured. |
UNKNOWN_REFERENCE | no | A step references a value, snapshot, or fixture that does not exist. |
UNAVAILABLE_CAPABILITY | no | The scenario requires a capability the harness does not provide. |
UNSUPPORTED_ENVIRONMENT | no | The scenario targets an environment this harness cannot build. |
INVALID_SEED | no | A seed is malformed or out of range. |
INVALID_TIMEOUT | no | A timeout value is malformed or not a positive duration. |
CONFIDENTIAL_PUBLIC_INPUT | no | Privacy 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. |
SERIALIZATION | no | Encoding or decoding a scenario, vector, or result failed. |
scenario-registry
| Code | Retryable | Raised when |
|---|---|---|
DUPLICATE_SCENARIO | no | A scenario with that id is already registered. |
UNKNOWN_SCENARIO | no | A lookup, filter, or replay names a scenario that is not registered. |
INVALID_FILTER | no | A 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.
| Code | Retryable | Raised when |
|---|---|---|
CONFIGURATION | no | The runner was configured inconsistently (for example, parallel workers with serial-only scenarios). |
SCENARIO_DEFINITION | no | The definition is valid per scenario-core but unusable as written. |
ENVIRONMENT | no | Building the scenario environment failed. |
TIMEOUT | yes | The scenario exceeded its timeout. Retryable only because a timing-dependent failure may not recur; a deterministic failure never will, and is classified as such. |
CANCELLED | yes | The run was cancelled before the scenario completed. Classified distinctly from a failure, so an abort is never reported as a defect. |
RETRY_EXHAUSTED | no | The declared retry limit was reached. |
NON_RETRYABLE | no | A failure occurred that the retry policy refuses to retry, by design: deterministic failures are never retried. |
ISOLATION | no | A scenario’s state leaked into another, or an isolation boundary was violated. |
CLEANUP | no | A cleanup hook failed after the scenario ran. |
HOOK | no | A lifecycle hook failed. |
INFRASTRUCTURE | yes | A transport or harness-level dependency failed. Distinct from NON_RETRYABLE precisely so the retry policy can treat the two differently. |
Assertions and fixtures
assertions
| Code | Retryable | Raised when |
|---|---|---|
ASSERTION_MISSING_OBSERVATION | no | An assertion expected an observation the run never produced. Usually a scenario bug rather than a system failure. |
ASSERTION_TYPE_MISMATCH | no | The observed value’s type does not match what the assertion compares against. |
ASSERTION_MISSING_FIXTURE | no | An assertion references fixture data that does not exist. |
ASSERTION_INTERNAL | no | An internal inconsistency in the assertion engine — a bug here, not a finding. |
fixtures
| Code | Retryable | Raised when |
|---|---|---|
FIXTURE_MISSING | no | A referenced fixture is absent from the catalog. |
FIXTURE_MALFORMED | no | A fixture failed to parse or violates its schema. |
FIXTURE_VERSION_MISMATCH | no | A fixture’s declared version is not one this harness supports. |
FIXTURE_INTERNAL | no | An 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
| Code | Retryable | Raised when |
|---|---|---|
INVALID_OPERATION | no | The operation could not be translated into a simulator call. |
adapters/prover
| Code | Retryable | Raised when |
|---|---|---|
NO_PROOF_FIXTURE | no | A proof was required but the pack provides no fixture posture for it. |
GENERATION | no | Proof generation through the prover surface failed. |
UNSUPPORTED_OPERATION | no | The prover surface has no handling for this operation. |
adapters/soroban
| Code | Retryable | Raised when |
|---|---|---|
UNKNOWN_CONTRACT | no | The contract surface names a function on a contract it does not know. |
UNKNOWN_FUNCTION | no | The operation has no contract-function translation. |
MISSING_ARGUMENT | no | A call was translated without a required argument. |
INVALID_ARGUMENT | no | An argument does not match the call’s expected type. |
CONFIDENTIAL_INVOCATION | no | Privacy guard: an invocation would place confidential material in publicly visible contract arguments. |
adapters/testnet
| Code | Retryable | Raised when |
|---|---|---|
TESTNET_NOT_CONFIGURED | no | The live-network path was used without explicit opt-in configuration. The adapter never silently falls back to a network. |
INVALID_CONFIGURATION | no | The network configuration is malformed (bad RPC URL, unsupported network). |
INVALID_EXECUTION | no | A network execution attempt is structurally invalid. |
POLL_TIMEOUT | yes | Polling for a transaction result exceeded its budget. The network may simply be slow. |
MALFORMED_STATUS | no | A 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.
| Code | Retryable | Raised when |
|---|---|---|
FIXTURE_INCONSISTENCY | no | A fixture contradicts another fixture or a declared expectation. Raised by the simulator and prover adapters as well as the Soroban adapter. |
INTERNAL | no | An 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:
- Discover — find the scenario through the registry.
- Validate — the scenario was already validated at build time; re-check against the current context (capabilities, environment, isolation).
- Prepare — prepare environment/fixtures; nothing here may run the scenario early.
- Initialize — build the
ScenarioContext, load initial state, seed the deterministic stream. - Execute — run the ordered operations through the context’s services.
- Observe — capture results as classified observations.
- Assert — evaluate the scenario’s assertions.
- Invariants — evaluate declared cross-operation invariants.
- Classify — produce the
ScenarioOutcomewith aStatusand, on failure, a classifiedFailure. - Report — hand the outcome to reporting.
- 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. - Tokens —
ct-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/andtest-vectors/generated/are derived from the code-defined catalog byscripts/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):
| Target | What it mutates | Oracle |
|---|---|---|
operations | random multi-operation scenarios (actors, tokens, amounts, op kinds) | cross-operation invariant registry (conservation, ownership, commitment consistency, replay protection, privacy) + no panic/error |
negative-controls | must-reject operations from the declared posture (unregistered actor, zero amounts, frozen account) | rejection expectations — acceptance is a finding |
proof-references | proof reference strings (wrong digest, unknown fixture, malformed, not-a-reference) | rejection + input-binding invariant — anything that verifies is a finding |
public-inputs | public 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
- A finding records
seed+iteration— replay the run with that seed. fuzz::reduce_findingdelta-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.FuzzFinding::to_regression_caseconverts the reduced finding into a permanentCT-REG-100..999case 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):
| Key | Checks that … |
|---|---|
conservation | total declared value is conserved across the run’s operations, computed from the declared amounts and the fixture starting ledger. |
ownership | state stays with the owner the operations imply, per protocol rules. |
commitment-consistency | commitment observations correspond to valid state relationships declared by the scenario. |
proof-binding | each proof generated in the run binds to the operation it claims to prove. |
input-binding | public-input mutations (adversarial runs) invalidate verification; binding is checked, not assumed. |
replay-protection | consumed or stale state is never reused. |
privacy | no 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:
- Type-level classification (
scenario-core::observation) — every observation carries aVisibility. Non-public values serialize as[REDACTED], andDebugnever renders them. A leak would require deliberately authoring a public observation with a private value. - Definition-time guard (
scenario-core::scenario) — aGenerateProofstep whose public inputs name a confidential field (amount, witness, secret, nonce, opening, randomness, confidential) fails to build. Confidential values never belong in public inputs. - Runner expectations —
ExpectationKind::NotDisclosedpins specific keys (e.g.op.op-transfer.amount) that must never become observations. - Privacy invariant (
invariants, keyprivacy) — 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. - 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:
| Id | Path | What it pins |
|---|---|---|
| CT-PRIV-001 | success | a confidential transfer completes while the 30-unit amount and witness material never become public observations |
| CT-PRIV-002 | failure | a rejected transfer discloses only its classification code, never the attempted 9000-unit amount |
| CT-PRIV-003 | proof | the proof statement binds only public protocol values; generation and verification observations never name amount/witness |
| CT-PRIV-004 | failure | a 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-proverbefore 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 compatibility —
crates/compatibility: a proof verifies against the same circuit version, is refused aswrong-circuitagainst 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
ProofEnvelopeassembly, - 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
| Id | Bug | What the case pins |
|---|---|---|
| CT-REG-001 | the assertion engine read the first observation for keys that are re-recorded as a run progresses | two deposits of 40 then 10 assert the final public balance of 50 |
| CT-REG-002 | an unregistered actor acting on another owner’s state was not refused by the registration check first | mallory’s transfer of alice’s state is refused as unregistered-account, not wrong-owner |
| CT-REG-003 | the ledger’s replay protection keyed on the operation id | resubmitting 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):
- Reconstruct the minimal operations that reproduce it.
- Add a case builder in
crates/regression/src/cases.rswhose scenario asserts the fixed behavior. - Register it in
regression::all(). - For fuzz findings, use
fuzz::reduce_findingto shrink to the minimal case andFuzzFinding::to_regression_caseto scaffold the case (ids land inCT-REG-100..999so 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
reportandrunoutputs are byte-stable: parallel execution returns outcomes re-sorted into registry order, so--parallelreports 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..." }
}
formatmust equalcrucible-scenarios/scenario; anything else is rejected with a structured error.schema_versionmust 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.
scenariois the serializedScenario— field names and shapes are exactly whatscenario-coreserializes. Reference documents live inexamples/declarative/.
Validation before execution
Parsing is validation-first. ScenarioDocument::from_json enforces, in
order:
- well-formed JSON,
- supported format id,
- supported schema version,
- typed deserialization of the body,
- 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
| Type | Meaning |
|---|---|
Scenario | A complete definition: metadata, environment, capabilities, actors, operations, expectations, assertions, invariants, seed, timeout, declared outcome. Validated at build time. |
ScenarioId | Permanent, grammar-validated identifier (CT-XFER-001, CT-XFER-NEG-001, CT-PROOF-REPLAY-001, REG-2026-001). |
ScenarioMetadata | Name, description, Category, Tags, pinned protocol/circuit/prover/simulator versions, references. |
Actor / ActorId / Role | Public synthetic identities (alice, bob, issuer, auditor, unauthorized…) with declared roles. Actors never carry credentials. |
Environment / EnvironmentKind | Where a run executes: mock, simulator, prover, soroban, testnet, end-to-end. Testnet is isolated. |
Capability / Capabilities | What a scenario requires vs. what a context offers (simulation, proof-provider, verifier, soroban-adapter, testnet, event-observation, snapshots, concurrency, replay-protection, deterministic-clock). |
Operation / OperationKind | Typed workflow steps: register, deposit, merge, confidential transfer, withdraw. Intents, not implementations. |
Expectation | Declarative claims: succeeds, rejected (with optional reason), replay-rejected, invariant-holds, not-disclosed. |
AssertionSpec / AssertionResult | Declared checks and their per-run outcomes. |
Observation / ObservationLog | What the run saw, classified public/private/sensitive/internal. |
ScenarioOutcome / Status | The per-run record: status, environment, seed, timings, observations, assertion results, invariants, failure. |
Failure / FailureCategory / LifecycleStage | Classified findings with category, stage, severity. |
Severity | info/low/medium/high/critical with elevated markers for security findings. |
Seed | Deterministic 64-bit seed; children per consumer; replay key. |
ScenarioContext | The 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
RetryPolicythat only retries explicitly configured infrastructure-level failures. Deterministic failures never retry and never get hidden. - Cleanup — a
Cleanupchain that runs after the outcome is fixed; cleanup problems are recorded as observations, never silently dropped. - Hooks —
HookChainwithbefore/afterphase hooks for instrumentation. - Invariant evaluation — the executor runs the scenario’s declared
invariants through an
InvariantRegistryand 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 theprover-adapterUltraHonk 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
| Mode | Backing | Status |
|---|---|---|
LOCAL_SIMULATION | in-memory double | implemented, default CI |
SIMULATOR_PLUS_PROVER | double + fixture prover | implemented |
LOCAL_SOROBAN | deployed local contracts | adapter ready, client not wired |
TESTNET | public test network | opt-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
- Run it alone with the failure detail.
Expected-failure packs (negative, adversarial) should fail — check the declared outcome matches the observed classification. Acargo build -p cli target/debug/crucible-scenarios run CT-XXX-001 target/debug/crucible-scenarios run CT-XXX-001 --jsonFAILstatus means the system did not behave as the scenario requires. - 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-holdsassertions failing with “not registered” mean the invariant key was misspelled or not attached. - Re-run in parallel.
run --parallelandreport --parallelmust produce byte-identical outcomes to serial. A difference is a genuine determinism bug (shared state, registry mismatch) — file it. - 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-simulatorthroughcrates/adapters/simulator; if you need proofs, consumecrucible-proverthroughcrates/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
- Pick a stable scenario ID (e.g.
CT-XFER-001) — IDs are permanent. - Declare initial state, operation sequence, expectations, assertions, invariants, timeout, seed, and required capabilities.
- Prefer the declarative scenario format (see
docs/scenario-format.md) for scenarios that need no custom Rust; use code-defined scenarios for complex cases. - Register the scenario and give it deterministic fixtures and test vectors.
- Add unit tests for any new model/utility code and, where possible, a regression record for every discovered bug.
- 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.
| Repository | Revision rendered |
|---|---|
crucible-simulator | cf0540e1d069 |
crucible-prover | 2ac52e4cba67 |
crucible-scenarios | f4f0b41213d7 |
Generated by this build.

