Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Soroban verification

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

Why it matters

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

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

How on-chain verification actually works

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

The deployed testnet verifier

Contract: CCS6Z3VVCKV4F5BCH7VXJLKKWMDROUWOTZYROJ4T26CM7R45SE4IFYI2

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

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

The on-chain proof-format pin (important)

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

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

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

What has landed (adapters/soroban)

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

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

On-chain fixtures

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

Agreement tests

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

Live agreement tests

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

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

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

Audit status

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

What remains

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