zkEnc — validity proofs
zkEnc gives ENC a succinct proof that an enclave's RBAC state was reached by valid transitions — so a node acting as prover cannot forge authorization, and a client verifies the whole history in one O(1) check: no replay, no trust in the operator for validity.
This is the implemented form of the validity story in the
litepaper; the normative protocol surface is /spec/node/zk.
It ships three things:
- a prover — a bounded Groth16 circuit plus an unbounded Nova-IVC folding prover;
- a prover service (
prover-service) that folds a node's transitions and serves proofs; - an out-of-circuit verifier (
verifier) — the client that checks a proof against the node's signed log.
Quick start (≈2 min, no node required)
Verify a real, captured validity proof end-to-end, offline:
git clone https://github.com/enc-protocol/enc
cd enc/zk/verifier
cargo run --release --bin verify_anchor -- ../e2e/anchor_vector.jsonFirst build is ~1 min; then it recomputes the verifying key (~8 s) and prints:
✓ ACCEPT — r_n is a valid RBAC successor of the log-anchored r_0, verified in O(1)
That fixture (e2e/anchor_vector.json) was captured from a live node: the verifier checks
the node's STH (Schnorr) + CT inclusion proofs and the folding proof — exactly
what a real client does.
Install
- Rust (stable) — the only hard dependency for building and testing.
- Node.js (18+) — only for the end-to-end drivers (they import the node SDK).
No system libraries and no trusted-setup ceremony are needed — the folding path uses transparent IPA commitments.
Run locally
Build & test each component
# bounded Groth16 circuit (root crate)
cargo test --release # circuit + live-node real-root validity proof + cross-impl parity
cargo run --release # the bounded prove → verify demo
# folding (Nova IVC) prover library
cd folding && cargo test --release
cd folding && cargo run --release --example fold_chain # fold a chain → compress → verify
# out-of-circuit client verifier
cd verifier && cargo test --releaseThe prover service + a verifier
The service folds a node's finalized transitions into one IVC chain per enclave and serves proofs; the verifier consumes one and anchors it to the node's signed log.
# 1. boot the service (precomputes PublicParams once, then listens on :8799)
cd prover-service && PROVER_ADDR=127.0.0.1:8799 cargo run --release
# 2. verify a captured proof offline (no node needed)
cd verifier && cargo run --release --bin verify_anchor -- ../e2e/anchor_vector.json
# 3. one service, many enclaves (folds one captured transition under N enclave ids)
PROVER_URL=http://127.0.0.1:8799 node e2e/multi_enclave.mjs # → /health enclaves=NAgainst a live node
The e2e/ drivers exercise a real node by pointing the driver's
PROVER_URL at a running prover and node.
Deploy
The prover is a native long-running service — folding proofs needs native assembly, so it
runs outside the node's WASM runtime. Run one prover container per node: it holds that
node's shared PublicParams (~900 MB) plus a hot LRU of per-enclave proof chains, and follows
the node's CT/STH stream to fold new transitions.
Operational shape:
- Compute — ~4 GiB RAM + native CPU per container, sized to
PublicParamsplus the hot-chain cap. Keep it warm; a cold boot re-derives params (~8 s). - Auth — require a strong bearer token on every
/enclave/*call. - Checkpoints — persist per-enclave chains to durable, S3-compatible object storage so a restart resumes instead of re-folding from genesis.
- Endpoints — clients poll
GET /root(cheap freshness) and fetchGET /proof(compressed, ~28 KB) only when the root advances;GET /healthreports the verifying-key fingerprint.
Pin the verifying key. A proof is only valid against the prover's vk — publish it once and have clients pin its fingerprint:
curl https://<prover-host>/health | jq -r .vk_fingerprintCost, performance & scaling
One container = one node's enclaves (a shared PublicParams plus a per-enclave IVC chain).
Measured for the UniversalStep (~152k constraints/step) on a full CPU core:
| Operation | Cost | Notes |
|---|---|---|
| Boot (params + pp + compress keys) | ~8 s | one-time per cold start — keep the instance warm |
| Fold one transition (prove + verify) | ~0.45 s | only state-changing transitions are folded |
| Compress + serve a proof (Spartan) | ~11–16 s | cache it; recompute on a cadence, not per query |
GET /root (freshness) | ~0.2 ms | cheap; poll this, fetch /proof only when needed |
| Compressed proof size | ~28 KB | served to clients (O(log n)) |
Memory is the sizing driver: ~900 MB (shared pp + commitment keys) + PROVER_MAX_HOT ×
~10 MB per hot chain. The defaults (max_hot=256) fit a 4 GiB instance. Cold chains live
in the store, not RAM, so the number of enclaves is bounded by storage, not memory.
The economics hinge on one fact: proving touches only state-changing transitions, and RBAC events are rare — so steady state is a warm, mostly-idle instance (paying memory) with occasional CPU bursts, not continuous proving. Two levers matter most: cache proofs (the ~11–16 s compress dwarfs everything; serve cached and recompress on a checkpoint cadence), and choose keep-warm (pay memory, skip the 8 s cold-start) vs scale-to-zero (pay cold starts) per your transition cadence. To scale past one instance, shard enclaves by id across containers — throughput scales linearly, with the ~900 MB pp as the fixed per-shard overhead.
How it works
| Role | Who | Trust |
|---|---|---|
| Sequencer | Node | Orders events; untrusted for validity |
| Prover | Node (or a delegated service) | Holds the witness; produces the proof |
| Verifier | Client | Public inputs + proof only, O(1) |
The validity relation old_root → new_root is proven in two complementary forms:
- Bounded — a Groth16 proof of one transition (or a fixed batch). On-chain-friendly, O(1) verify.
- Unbounded (folding) — a Nova IVC proof folding the enclave's entire transition
history (O(1) prover work per step), then Spartan-compressed to one succinct proof. Needed
because the SMT also holds arbitrary app state (KV namespace
0x02), so transitions are unbounded over an enclave's lifetime.
The SMT hashes with Poseidon2 over BN254 (t=3, x^5, R_F=8, R_P=56) so membership
is proven natively in-circuit, byte-identical across the JS node, the encvm Rust logic, and
every circuit here. Signatures, CBOR/event hashing, CT inclusion, and STH verification stay
out of circuit (ordinary crypto), linked to the proof only by equality of the public
roots — so the client does ~3 cheap checks plus the O(1) proof.
Why this stack
Every choice is forced by the shape of ENC's problem: a node is an untrusted sequencer, each enclave's state is a Sparse Merkle Tree, transitions are RBAC events, and the client must verify in O(1). The hard part is proving SMT membership inside the circuit; everything else is comparatively cheap.
- Folding (Nova IVC), not just a bounded SNARK. The SMT holds arbitrary application state, so transitions per enclave are unbounded — a bounded circuit always has a ceiling. Nova folds each transition into one running instance (O(1) work/step, constant memory), then Spartan-compresses to one O(1)-verifiable proof of the whole history.
- Poseidon2 over BN254 — match the hash to the field. SMT membership dominates the
circuit. A byte-oriented hash (SHA-256) costs tens of thousands of constraints per call; an
algebraic
x^5hash costs a few hundred. BN254 is both the Groth16 pairing curve and the base of Nova's BN254/Grumpkin cycle, so one field serves the SMT, the bounded circuit, and the folding cycle. - Transparent IPA commitments — no ceremony. ENC is permissionless, so a per-circuit
trusted setup is a trust liability. Nova on IPA/Pedersen is transparent: no setup,
deterministic verifying key. The tradeoff is an
O(log n)(notO(1)) compressed proof, acceptable for a client already doing CT/STH checks. - Witness the op and the schema — one
PublicParamsfor the whole node. Nova requires identical constraint matrices across folded steps. zkEnc makes the op a one-hot input and the schema a witnessed input bound to a manifest commitment, so one ~900 MBPublicParamsfolds every enclave's transitions under any schema — which is what lets a singleprover-serviceserve a whole node. - Prove only what needs proving; bind the rest by equality. Putting SHA-256, CBOR, and Schnorr in R1CS would cost millions of constraints per event for zero soundness gain. zkEnc keeps them out of circuit and links them by equality of the public field elements — reusing ENC's existing STH/CT/Schnorr infrastructure instead of re-deriving it in a SNARK.
The net: unbounded history → folding; membership-dominated cost → Poseidon2/BN254; permissionless prover → transparent IPA; many enclaves/schemas → witnessed op+schema, one shared key; existing signature/log infra → out-of-circuit binding. Each is the cheapest sound choice for this relation.