Prova's on-chain programs — Rust + Soroban (Stellar's smart contract platform). Two contracts, one Cargo workspace.
contracts/
Cargo.toml workspace: members = ["verifier", "pool"]
rust-toolchain.toml pins stable + wasm32 target
DEPLOYMENTS.md live testnet contract IDs, tx hashes, post-deploy verification checks
verifier/ circuit-v2 verifier — per-transfer proof, no custody (older, simpler)
pool/ circuit-v3 shielded pool — real token custody, notes, private transfers
scripts/
deploy_testnet.sh build + deploy the verifier
deploy_pool_testnet.sh build + deploy + initialize the pool (reads the anchor key from the
prover binary, so the on-chain value can never drift from the circuit)
On-chain, neither contract ever sees an amount or an identity — only commitments, nullifiers,
and Groth16 proofs. Both verify BLS12-381 proofs using Soroban's native env.crypto().bls12_381()
host functions (g1_msm, pairing_check) against a verifying key embedded at compile time via
include_bytes!, generated by the prova-prover CLI in ../circuits/prover (see its README).
The original design: one Groth16 proof per transfer, proving a statement (amount in range,
commitment/nullifier correct, sender KYC'd) without moving any custodied value on-chain. The
"balance" this proves against lives outside the contract (currently a local counter on the phone) —
this contract is a statement verifier, not a vault. See pool/
below for the version that actually custodies tokens.
Entrypoints (src/lib.rs):
| Function | Does |
|---|---|
verify(proof_a, proof_b, proof_c, commitment, nullifier, anchor_pk_x, anchor_pk_y, current_time) |
Pure check — runs the pairing equation, returns true/false. No state change. |
submit(...) |
Verifies, rejects an already-used nullifier (Error::NullifierAlreadyUsed), records the commitment + nullifier, emits a transfer event ((commitment, nullifier)) for the indexer. |
is_spent(nullifier) |
Read-only nullifier lookup. |
is_committed(commitment) |
Read-only commitment lookup. |
The verifying key is embedded from src/verifying_key.bin, laid out as
alpha(96) ‖ -beta(192) ‖ -gamma(192) ‖ -delta(192) ‖ IC0..IC5(96 each) — beta/gamma/delta are
pre-negated at generation time so the whole Groth16 check collapses to a single
pairing_check(A,B, alpha,-beta, vk_x,-gamma, C,-delta) == 1.
The real value layer — see Docs/shielded-pool.md for the full design.
Real tokens go in, value moves privately between notes inside the contract, real tokens come out. An
on-chain observer sees only commitments, nullifiers, and proofs.
A measured Poseidon permutation costs ~10,967,507 CPU instructions against Soroban's 100M
per-transaction budget. A depth-20 Merkle append needs 20 of them and cannot even run to
completion — so the contract cannot maintain its own Merkle tree. (The measurement is kept
executable in the test-only gate module: test::gate_onchain_merkle_does_not_fit_cpu_budget.)
The fix is to defer and batch every tree update:
shield / transact / unshield → verify a proof, queue the new commitments (no hashing)
update_root → verify one proof that folds the queue in (no hashing)
update_root is permissionless. The fold proof (see circuits/README.md) enforces correctness,
so whoever calls it can neither mint, steal, nor spend — their only power is to stop calling it,
which delays new notes from becoming spendable and puts no custodied funds at risk. In production
this is the backend's folder (backend/internal/pool/folder.go), but nothing stops anyone else from
running one.
Consequence: a note is spendable only once the fold containing it has landed — a spend proves Merkle membership, and an unfolded commitment isn't a leaf yet. Wallets must show queued notes as confirming, not spendable.
Mirrored bit-for-bit in circuits/prover/src/pool/mod.rs, shared/src/pool.ts, and
shared/go/schema/pool.go — a mismatch here silently breaks value conservation or makes notes
permanently unspendable, not something that fails loudly.
| Constant | Value | Meaning |
|---|---|---|
DEPTH |
20 | tree depth (only used to bound next_index; the contract never walks the tree) |
BATCH |
8 | commitments per update_root call (chosen from measured MSM cost: 8 leaves ≈ 60M CPU, 16 ≈ 71M, 32 ≈ 95M — 8 leaves ~40M headroom) |
ROOT_HISTORY |
32 | how many recent roots a spend may still prove against |
Three verifying keys are embedded via include_bytes! from src/artifacts/ (spend_vk.bin,
shield_vk.bin, fold_vk.bin), plus empty_root.bin (the root of an empty tree — the contract
can't derive this itself, since that's 20 Poseidon hashes). All are generated by
prova-prover pool-artifacts (see circuits/README.md) and must be regenerated together whenever
any pool circuit changes.
| Function | Does |
|---|---|
initialize(admin, token, anchor_pk_x, anchor_pk_y) |
One-shot. Binds the pool to its custodied token and the anchor whose KYC credentials it trusts; seeds the empty root into history. Cannot be re-run — a wrong admin means redeploying under a new contract ID. |
shield(from, amount, note, proof) |
Moves real tokens into the pool, queues the resulting note. Deliberately public (the anchor already knows about the deposit via KYC/Travel Rule) — privacy applies in transit, not at the funding boundary. The shield proof is what stops a user depositing 100 while committing to 1,000,000. |
transact(proof, nullifier, merkle_root, outputs, current_time) |
A private transfer: one note in, two notes out, nothing public but the nullifier and two new commitments. |
unshield(proof, nullifier, merkle_root, outputs, amount, to, current_time) |
Withdraws real tokens to a public Stellar address. Uses the same SpendCircuit as transact (only publicAmount/destination differ), so an on-chain observer cannot distinguish a private transfer from a cash-out by shape alone. |
update_root(proof, new_root, count) |
Permissionless. Verifies a fold proof and advances the tree root + queue head by count. |
upgrade(new_wasm_hash) |
Admin-only. Replaces the contract's code — the single most dangerous entrypoint in the system (see Docs/deployment-and-keys.md §1). |
set_anchor(anchor_pk_x, anchor_pk_y) |
Admin-only. Rotates the trusted KYC-signing key; takes effect immediately and invalidates every outstanding credential (honest ones included) — correct behavior after a real key compromise, so re-issue proactively for a planned rotation. |
set_paused(paused) |
Admin-only. Halts shield/transact/unshield. Withdrawals are never paused by design — unshield still runs; only new deposits/transfers stop. Folding also continues while paused, so already-queued notes still become spendable. |
set_admin(new_admin) |
Admin-only. Hands off the admin role — e.g. to a multisig before mainnet. |
admin(), is_paused(), is_known_root(root), root(), next_index(), queue_depth(), is_spent(nullifier) |
Read-only state queries. queue_depth is the number to watch operationally — a growing, non-draining queue means the folder has stalled (no funds at risk, but nothing new becomes spendable). |
extend_ttl(nullifier) |
Housekeeping: extends the storage TTL on a nullifier entry so long-lived pool state doesn't get archived out from under it. |
destination_field(destination) |
Helper: derives the field element a spend proof binds an unshield destination to. |
Types worth knowing: Proof { a, b, c } (bundled because Soroban caps a contract function at 10
parameters); Outputs (the two notes a spend produces, plus their shared ephemeral encryption key —
the encrypted payloads are public inputs to the proof, not attachments, so corrupting one
invalidates the proof rather than silently stranding the recipient's money); ShieldNote (a
deposit's commitment + encrypted copy for its owner, same "proof, not attachment" guarantee).
gate.rs and poseidon.rs are #[cfg(test)]-only and never ship in the production wasm — the
production contract never hashes, which is the entire finding this design rests on. They're kept so
the CPU-cost measurement stays re-runnable and so the backend indexer/folder have a reference
Poseidon implementation to test against. pool/src/test.rs generates its proofs from the real
circuits (prova-prover as a dev-dependency) rather than replaying fixtures, so a change that
breaks circuit/contract agreement fails a contract test, not just a circuit test.
cd contracts
cargo test # both crates — pool's tests build real Groth16 proofs
stellar contract build --optimize # optimized wasm for both crates
cargo fmt --all --check # CI enforces this
# verifier
./scripts/deploy_testnet.sh
# pool — requires funded prova-admin + prova-test identities (Docs/deployment-and-keys.md §3)
TOKEN_ID=<SAC address> ./scripts/deploy_pool_testnet.shFull step-by-step deployment, key generation, and admin break-glass operations (pause, rotate the
anchor key, upgrade, hand off admin) are documented in
Docs/deployment-and-keys.md. Live deployed contract IDs, transaction
hashes, and their post-deploy on-chain verification checks are tracked in
DEPLOYMENTS.md.
Both contracts are deployed and verified on Stellar testnet. The verifier (circuit v2) accepts real
proofs and rejects tampered/replayed ones. The pool (circuit v3) is initialized with a real anchor
key, has an independently-verified empty-tree root, and its shield/transact/unshield/update_root
cycle is covered by contract tests built against the real circuits. Before mainnet: a real
multi-party trusted-setup ceremony (the current setup seed is public/deterministic, testnet-only),
migrating the admin key to a multisig via set_admin, and an independent security audit — see
Docs/implementation-guide.md Phase 5.