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.