Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

contracts

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).

verifier/ — circuit v2 (per-transfer proof)

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.

pool/ — circuit v3, the shielded pool

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.

The defining constraint: this contract never hashes

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.

Frozen parameters

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.

Entrypoints (src/lib.rs)

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).

Test-only modules

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.

Develop

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.sh

Full 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.

Status

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.