Caatinga ships a ZK workflow for BLS12-381 Groth16 proofs verified on Soroban via the
official groth16_verifier pattern.
| Choice | Why |
|---|---|
| BLS12-381 + Groth16 | Uses the BLS12-381 host functions (Protocol 22, CAP-0059) and follows stellar/soroban-examples/groth16_verifier. |
| BN254: out of scope | A Caatinga scope decision, not a network limitation: Soroban has BN254 host functions since Protocol 25 (CAP-0074), extended in Protocol 26 (CAP-0080). Caatinga ships one curve end to end; BN254 is not on the roadmap today. |
| Item | Status |
|---|---|
| Circom + snarkjs + Groth16 + BLS12-381 | Supported |
Dynamic VK (verify_proof) |
Supported (default) |
Embedded VK (verify_proof_embedded, --embed-vk) |
Supported — see Dynamic VK vs embedded VK |
MPC ceremony import (ctg zk import-ceremony) |
Supported; running the ceremony itself is out of scope |
Offline ceremony verification (ctg zk verify-ceremony) |
Supported |
| BN254 | Out of scope |
| Noir / UltraHonk, zkVMs | Not supported |
| Automatic Circom install | Linux and macOS; on Windows, put circom on PATH manually |
- Protocol 27+ on the target network. BLS12-381 itself only needs Protocol 22, but the verifier is built with
soroban-sdk27, and a contract built with SDK major N declares protocol N in its wasm: a network below that rejects the upload. The protocol pre-flight checks this before any transaction is built. - Verifier contract:
soroban-sdk = "27.0.6", Rust1.91.1. - Tooling: Circom 2 and snarkjs (installed on first use into
~/.caatinga/zk-tools). The firstctg zk buildprints download and setup progress in the terminal (circom binary, snarkjs cache, dev powers-of-tau). Everycircombinary — freshly downloaded or read from cache — is verified against a pinned SHA-256 before use; a mismatch deletes the file and raisesZK_CHECKSUM_MISMATCHinstead of running an unverified binary (see errors reference).
npx ctg zk init my-zk-dapp
cd my-zk-dapp && npm install
npx ctg zk build main
npx ctg build verifier
npx ctg deploy verifier --network testnet --source alice
npx ctg zk prove main
npx ctg zk invoke main --network testnet --source aliceWalkthrough: ZK project. Command loop: Cheatsheet — ZK loop.
| Command | Purpose |
|---|---|
ctg zk init [project] |
Scaffold zk-starter (multiplier circuit + verifier). |
ctg zk init [project] --minimal |
Scaffold a ZK-only project with a minimal identity circuit and verifier. |
ctg zk init [project] --template <name> |
Use a specific template instead of the default zk-starter. |
ctg zk init [project] --force |
Overwrite existing scaffold files. |
ctg zk build [circuit] |
Compile Circom (-p bls12381) and run dev trusted setup. Imported ceremony artifacts are reused when present. |
ctg zk build [circuit] -n, --network <name> |
Network the artifacts are meant for (defaults to defaultNetwork); the mainnet guardrail checks this network. |
ctg zk build [circuit] --embed-vk |
Also emit contracts/verifier/src/vk.rs, enabling the verify_proof_embedded entrypoint. |
ctg zk build [circuit] --allow-dev-ceremony |
Allow dev-ceremony artifacts on mainnet (not for production). |
ctg zk import-ceremony [circuit] --ceremony <path> |
Replace dev artifacts with an external MPC ceremony's circuit_final.zkey + verification_key.json, pinned by sha256. |
ctg zk verify-ceremony [circuit] |
Offline check of an import: artifact hashes, snarkjs zkey verify, and the VK against the zkey. |
ctg zk prove [circuit] |
Generate proof.json and public.json from input.json. |
ctg zk prove [circuit] --debug |
Emit intermediate witness.wtns for debugging. |
ctg zk invoke [circuit] --source <id> |
Serialize snarkjs output and call verify_proof on-chain (dynamic VK). --source is required; also -n, --network, -y, --yes, --allow-dev-ceremony. |
ctg zk invoke [circuit] --embed-vk |
Call verify_proof_embedded instead, using the VK baked into the contract. Requires ctg zk build <circuit> --embed-vk and ctg upgrade verifier. |
Artifacts land in .artifacts/zk/<circuit>/. ctg estimate verify <circuit> reports the
CPU/memory budget before you deploy.
See ZK project tutorial for the comparison table.
See ZK project tutorial for the hybrid UI + CLI prove flow.
circuits/input.json must contain the circuit's input signals only. Do not include
derived outputs — for the multiplier scaffold, use a and b but not c. Snarkjs derives
public signals during proving and writes them to .artifacts/zk/<circuit>/public.json.
If you include a derived output in input.json, ctg zk prove fails with a witness error
(Too many values for input signal c). Caatinga rewrites that failure as
CAATINGA_ZK_INPUT_CONTAINS_PUBLIC_SIGNALS with a hint naming the offending signal, so the
fix is obvious rather than a guess from raw snarkjs stderr.
ctg status reports ZK drift alongside contract and binding status:
- whether the circuit has been built, and the recorded ceremony type
- whether the circuit sources changed since an imported ceremony
- whether
input.jsonchanged since the lastctg zk prove
npx ctg status
npx ctg status --jsonZK drift never affects ctg status --strict: that flag guards binding freshness only, so
widening it would silently change existing CI outcomes.
After ctg zk build, Circom emits WASM under:
.artifacts/zk/<circuit>/main_js/main.wasm
The CLI resolves this path automatically during ctg zk prove; you do not configure it
in caatinga.config.ts.
import { serializeProof, serializeVk, serializePublicSignals } from "@caatinga/zk";import {
buildCircuit, // compile Circom circuit
proveCircuit, // generate proof from input.json
invokeVerifier, // call verify_proof on-chain
buildStellarVerifyProofArgs, // build args for browser read/simulate
ptauSizeForConstraints, // determine powers-of-tau file size
} from "@caatinga/zk";import {
buildVerifyProofBindingArgs, // build args for browser verify_proof read
concatG1Bytes, // concatenate G1 point bytes
concatG2Bytes, // concatenate G2 point bytes
} from "@caatinga/zk/browser";| Type | Source | Purpose |
|---|---|---|
SnarkjsProof |
@caatinga/zk |
snarkjs JSON proof shape |
SnarkjsVk |
@caatinga/zk |
snarkjs JSON verification key shape |
SerializedProof |
@caatinga/zk |
Byte-serialized proof for Soroban |
SerializedVk |
@caatinga/zk |
Byte-serialized VK for Soroban |
BuildCircuitOptions |
@caatinga/zk |
Options for buildCircuit() |
ProveCircuitOptions |
@caatinga/zk |
Options for proveCircuit() |
InvokeVerifierOptions |
@caatinga/zk |
Options for invokeVerifier() |
InvokeVerifierResult |
@caatinga/zk |
Result of invokeVerifier() |
SerializedG1 |
@caatinga/zk/browser |
Byte-serialized G1 point |
SerializedG2 |
@caatinga/zk/browser |
Byte-serialized G2 point |
VerifyProofBindingArgs |
@caatinga/zk/browser |
Args for browser binding read |
VerifyProofBindingBuffers |
@caatinga/zk/browser |
Raw buffer form of binding args |
ZkError is thrown by ZK library functions with a code property:
| Code | Meaning |
|---|---|
ZK_VK_REQUIRED |
Verification key is required but not provided |
ZK_INVOKE_FAILED |
On-chain verification call failed |
ZK_VERIFY_FAILED |
On-chain verifier returned false (maps to CAATINGA_ZK_VERIFICATION_FAILED in the CLI) |
ZK_DEV_CEREMONY_BLOCKED |
Mainnet blocked for dev-ceremony artifacts (maps to CAATINGA_ZK_DEV_CEREMONY_BLOCKED) |
ZK_UNSUPPORTED_PLATFORM |
Operation not supported on current platform |
ZK_DOWNLOAD_FAILED |
Downloading a ZK tool (Circom/snarkjs) failed |
ZK_CHECKSUM_MISMATCH |
A downloaded ZK tool did not match its pinned checksum |
| Mode | When to use |
|---|---|
| Dynamic VK (default) | VK passed as a contract argument; flexible while the circuit is still changing. |
--embed-vk |
VK baked into the contract as vk.rs; use once the circuit is frozen. |
Embedded VK is opt-in and visible in your repo — never a hidden dependency.
Both entrypoints live in the verifier scaffold, so enabling one does not remove the other:
| Entrypoint | Arguments | Use with |
|---|---|---|
verify_proof |
vk, proof, pub_signals |
dynamic VK |
verify_proof_embedded |
proof, pub_signals |
ctg zk invoke <circuit> --embed-vk |
ctg zk build --embed-vk writes contracts/verifier/src/vk.rs with real BLS12-381
coordinates from verification_key.json, and the verifier gains a
verify_proof_embedded entrypoint. Re-run the command after circuit changes to regenerate
the file — the CLI rejects --embed-vk invokes while vk.rs is still the scaffold
placeholder or absent (CAATINGA_ZK_EMBED_VK_UNSUPPORTED).
The key is compiled into the contract, so enabling it means shipping new verifier WASM. The
verifier scaffold is admin-gated and upgradeable, so ctg upgrade swaps the WASM in place
and keeps the contractId:
npx ctg zk build main --embed-vk
npx ctg zk prove main
npx ctg build verifier
npx ctg upgrade verifier --network testnet --source alice
npx ctg zk invoke main --embed-vk --network testnet --source alice- Re-prove after
--embed-vkon a dev ceremony.ctg zk buildruns a fresh dev trusted setup every time, so the keys — and thevk.rsit writes — change, and an olderproof.jsonno longer verifies (ctg statusreports it asvk-changed). With an imported MPC ceremony the keys are reused and existing proofs stay valid. - The admin signs the upgrade. The verifier's constructor takes an
adminaddress, and the scaffold config passes the deploying identity:deployArgs: { admin: "${source.address}" }.ctg upgrade verifiercallsupgrade(new_wasm_hash), which requires that admin's signature, so pass the same--sourceyou deployed with. Any other signer is rejected and the WASM stays as it was. The admin is kept in the contract's instance storage, so it lives as long as the contract instance (extend the instance TTL as you would for any long-lived contract).ctg upgrade --generate --sync-envalso refreshes the bindings (now withverify_proof_embedded) and the frontend env. - Verifiers scaffolded before 3.13.0 need one redeploy. Those have no
upgradeentrypoint, soctg upgrade verifierfails against them withCAATINGA_INVOKE_FAILED. CopyDataKey, theAdminNotSeterror variant,__constructor,adminandupgradefrom a freshctg zk initproject'scontracts/verifier/src/lib.rsinto yours, adddeployArgs: { admin: "${source.address}" }to theverifierentry incaatinga.config.ts, rebuild, and runctg deploy verifier --upgradeonce: that deploys a new, upgradeable instance and records the oldcontractIdin the artifact history. Later key changes usectg upgrade. (ctg zk init --forcewould also do it, but it overwritescircuits/too.)
The upgraded verifier still has verify_proof, so dynamic-VK callers work against it unchanged.
If ctg zk invoke --embed-vk reaches a verifier still running WASM built before vk.rs was
generated, the contract traps on its placeholder key and the CLI reports
CAATINGA_ZK_EMBED_VK_UNSUPPORTED.
Both entrypoints share one verification routine, so the result is the same for the same
key: CI runs the verifier scaffold under cargo test with a vk.rs generated by
ctg zk build --embed-vk and checks that a real proof is accepted by both entrypoints, and
that verify_proof_embedded rejects it when vk.rs was generated from a different key.
Upgrade vs redeploy are different operations.
ctg upgradeswaps the WASM in place and keeps thecontractId; it works for contracts that expose an admin-gatedupgrade(new_wasm_hash), which the ZK verifier scaffold does.ctg deploy --upgradecreates a new instance with a newcontractId— only needed for verifiers without that entrypoint.
ctg zk build runs a single-party development ceremony unless audited ceremony
artifacts have already been imported, and records the type in
.artifacts/zk/<circuit>/ceremony.json (dev-single-party or mpc-multi-party).
A dev ceremony is suitable for local testing only — production requires an external MPC
ceremony and audited circuit artifacts.
Caatinga blocks mainnet operations that would use dev-ceremony artifacts:
| Command | Guardrail |
|---|---|
ctg zk build |
Fails when the target network (--network, else defaultNetwork) is mainnet and no imported ceremony artifacts exist |
ctg deploy <verifier> |
Fails on mainnet when dev ceremony artifacts exist for linked circuits, or imported ones changed since import |
ctg zk invoke |
Fails on mainnet when dev ceremony artifacts exist, or imported ones changed since import |
ctg zk import-ceremony replaces dev artifacts with the output of an external ceremony and
lifts the mainnet block for that circuit:
npx ctg zk build main
npx ctg zk import-ceremony main --ceremony /path/to/ceremony-output \
--source-url https://example.org/ceremony --ceremony-name multiplier-2026 --participants 12
npx ctg zk verify-ceremony mainPoint --ceremony at a directory holding circuit_final.zkey and verification_key.json
(as produced by snarkjs zkey export verificationkey), plus the ceremony's final .ptau
when you have it. The command copies those artifacts, records mpc-multi-party in the
manifest, and verifies the artifacts match the compiled circuit by comparing the
public-signal count — a mismatch fails with CAATINGA_ZK_CEREMONY_INCOMPATIBLE instead of an
unexplained on-chain verification failure. --source-url, --ceremony-name,
--participants and --retrieved-at are recorded for auditors; Caatinga does not fetch or
check them.
The import pins every artifact by sha256 in ceremony.json. Mainnet ctg deploy <verifier>
and ctg zk invoke re-hash the proving key, the verification key and the powers-of-tau
before building a transaction, and fail with CAATINGA_ZK_CEREMONY_TAMPERED if any changed —
--allow-dev-ceremony does not waive this. A hand-written mpc-multi-party manifest with no
hashes is rejected the same way, so editing ceremony.json cannot lift the mainnet block.
ctg zk verify-ceremony [circuit] re-checks an import offline and reproducibly:
- re-hashes every pinned artifact;
- runs
snarkjs zkey verify <r1cs> <ptau> <zkey>when a.ptauwas imported, proving the proving key derives from this circuit and that powers-of-tau (skipped with a warning otherwise); - exports the verification key from the zkey and compares it with
verification_key.json, the key the verifier is actually fed.
Any failure is CAATINGA_ZK_CEREMONY_TAMPERED. On success the manifest gains verifiedAt.
The checks bind the artifacts to each other and to the circuit; whether the ceremony itself
was run honestly is still for you to audit from its published transcript.
ceremony.json is a public contract. An imported ceremony writes version 2:
{
"type": "mpc-multi-party",
"version": 2,
"generatedAt": "2026-10-16T12:00:00.000Z",
"circuitHash": "<sha256 of the .circom sources>",
"publicSignals": 1,
"source": "/path/to/ceremony-output",
"artifacts": {
"zkey": { "path": "circuit_final.zkey", "sha256": "…" },
"verificationKey": { "path": "verification_key.json", "sha256": "…" },
"r1cs": { "path": "main.r1cs", "sha256": "…" },
"wasm": { "path": "main_js/main.wasm", "sha256": "…" },
"ptau": { "path": "pot12_final.ptau", "sha256": "…" }
},
"provenance": {
"sourceUrl": "https://example.org/ceremony",
"ceremony": "multiplier-2026",
"participants": 12,
"retrievedAt": "2026-10-16T12:00:00.000Z",
"curve": "bls12381"
},
"verifiedAt": "2026-10-16T12:05:00.000Z"
}Paths are relative to .artifacts/zk/<circuit>. ptau, r1cs and wasm are optional;
provenance fields other than retrievedAt and curve are optional. r1cs and wasm are
recompiled by ctg zk build, so a change to them is reported by verify-ceremony but does not
block mainnet. Dev ceremonies keep the unversioned { "type": "dev-single-party", "generatedAt" }
shape. Compatibility: an mpc-multi-party manifest without artifacts (written by an
earlier pre-release import-ceremony) is rejected on mainnet; re-run ctg zk import-ceremony
to upgrade it. New optional fields may be added in a minor release; removing or renaming one
is a breaking change.
After an import, ctg zk build reuses the imported artifacts instead of running a new
dev ceremony, so a rebuild cannot silently demote a production build.
Pass --allow-dev-ceremony only for conscious testing — not for production deployments.
The CLI surfaces CAATINGA_ZK_DEV_CEREMONY_BLOCKED when a guardrail trips.
Measured on testnet (protocol 29, 2026-10-16) by scripts/zk-verify-benchmark.mjs, which
simulates verify_proof (dynamic VK) against a freshly deployed Caatinga verifier with the
same arguments ctg zk invoke sends:
| Public signals | CPU instructions | Resource fee (stroops) | Tx size (bytes) |
|---|---|---|---|
| 1 | 43,116,283 | 22,412 | 1,772 |
| 4 | 53,785,599 | 26,528 | 2,192 |
| 16 | 96,462,864 | 43,137 | 3,872 |
| 64 | 267,171,922 | 110,462 | 10,592 |
| 100 | 395,203,714 | 160,956 | 15,632 |
- Cost depends on the public-signal count, not on the number of constraints: the pairing
check is fixed (≈40.5M instructions) and each public signal adds one G1 multiplication
(≈3.56M).
ctg estimate verifyuses that model and rounds up. - Testnet and mainnet both cap a transaction at 400M instructions and 40 MiB of
memory (
contract_compute_v0), so the practical ceiling is about 100 public signals — 112 failed simulation. Hash many values into a few public signals if you need more. - The transaction stays far below the 132 KB size limit at every size measured.
- Fees are the simulated resource fee at measurement time; they move with network fee settings. A verifier whose instance has been archived also pays to restore it on the next call, which can dominate the fee.
The weekly zk-smoke job re-runs the benchmark and publishes the table in its job summary
and in the zk-testnet-smoke-artifacts upload. For your own circuit, simulate the real call:
stellar contract invoke --send=no -- verify_proof ....
The same job checks that the deployed verifier rejects an invalid proof, altered public
inputs, a mismatched verification key and swapped G2 limbs, and that a tampered imported
zkey is refused by ctg zk verify-ceremony and on mainnet before any transaction is built.
These are simulations of a read-only call, so they spend no XLM.
export default defineConfig({
// ...
zk: {
circuits: {
main: {
path: "./circuits",
protocol: "groth16",
curve: "bls12381",
verifierContract: "verifier", // contract name used by `ctg zk invoke`
},
},
},
});Only bls12381 is accepted today; other curves fail config validation. verifierContract is optional in the schema, but ctg zk invoke requires it: without it the command fails with Verifier contract not configured for circuit "<name>".
ctg deploy <verifier> and ctg zk invoke check the target network's protocol version via
Soroban RPC getNetwork before building a transaction. The minimum is protocol 27: the
verifier wasm is built with soroban-sdk 27 and declares that protocol, so an older network
would reject it. A network below that fails with CAATINGA_ZK_PROTOCOL_VERSION_UNSUPPORTED.
The minimum follows the pinned SDK major; a test keeps the two in sync.
The check is advisory in ctg doctor, which prints each configured network's protocol
version without ever making the run fail.
The zk-starter verifier scaffold compiles against the reference contract shape and is
validated end-to-end on a live network by scripts/zk-testnet-smoke.sh, which the
zk-smoke job in .github/workflows/testnet-smoke.yml runs weekly (or on demand through
workflow_dispatch with zk: true). Run it yourself with a funded testnet identity:
CAATINGA_CI_IDENTITY_ALIAS=alice bash scripts/zk-testnet-smoke.sh.