Audience: Developers building zero-knowledge features on Soroban / Stellar.
Purpose: Secure design patterns, common pitfalls mapped to Z-rules, and a pre-deployment checklist.
Related: Threat Model, Individual Z-rule docs, Security Checklist
- ZK on Soroban — Overview
- Secure Design Patterns
- 2.1 Nullifiers
- 2.2 Commitments and Blinding Factors
- 2.3 Public-Input Binding
- 2.4 Trusted-Setup Key Management
- 2.5 Domain Separation
- 2.6 Merkle Membership Proofs
- Z-Rule Catalog
- Pre-Deployment Security Checklist
- Further Reading
Soroban smart contracts can act as on-chain ZK verifiers: they receive a cryptographic proof, verify it against a committed verifying key, and take an action (mint, transfer, vote, update state) if the proof is valid.
This pattern is powerful but introduces an entirely new attack surface beyond traditional smart-contract security. The ZK verifier sits at the intersection of:
- On-chain authorization logic — who can call which function, and when?
- Cryptographic correctness — is the proof system instantiated correctly?
- Application-layer soundness — does a valid proof actually mean what the contract thinks it means?
Sanctifier's Z-rule set (Z001–Z014) covers the recurring vulnerability classes at this intersection, grounded in real-world audit findings and post-mortems.
| Principle | One-liner |
|---|---|
| Consume proofs exactly once | Every verified proof must leave a nullifier record (Z001, Z006) |
| Bind proofs to context | Public inputs must commit to the caller, recipient, amount (Z003) |
| Guard the verifying key | Rotation must require admin auth; integrity must be checked before use (Z004, Z005, Z010) |
| Range-check all field elements | Public inputs must be validated before the verifier call (Z007) |
| Separate commitment domains | Never reuse a hash function across distinct commitment types (Z011) |
| Verify against your root | Merkle proofs must check against the contract's committed root (Z014) |
A nullifier is a unique, deterministic identifier derived from a secret (e.g. the leaf's secret key). It is stored after a proof is consumed to prevent replay.
Pattern:
pub fn claim(env: Env, proof: Vec<u64>, public_inputs: Vec<u64>) {
let nullifier = public_inputs.get(0).expect("nullifier required");
// 1. Check — reject if already spent.
let key = (symbol_short!("null"), nullifier);
assert!(!env.storage().persistent().has(&key), "proof already consumed");
// 2. Verify — proof is sound.
verify_proof(&env, &proof, &public_inputs);
// 3. Effect — take the action.
transfer_funds(&env, &public_inputs);
// 4. Record — mark as spent.
env.storage().persistent().set(&key, &true);
}Commitments hide a value while binding the prover to it. The blinding factor must be:
- Unpredictable — generated off-chain by a secure RNG, never derived from ledger sequence, timestamp, or other on-chain predictables.
- Secret — never included in any event, log, or public output.
Pattern:
// Caller generates blinding factor off-chain (e.g. with `crypto.getRandomValues`)
// and passes it as a private input to the circuit. The contract never sees it.
pub fn commit(env: Env, commitment: BytesN<32>) {
env.storage().persistent().set(&DataKey::Commitment, &commitment);
}Every ZK-verifier entry point must include transaction-specific context in the public inputs: caller, recipient, amount, and contract ID. This prevents an attacker from taking a valid proof and redirecting its effect.
Pattern:
pub fn withdraw(env: Env, proof: Vec<u64>, recipient: Address, amount: i128) {
// Validate range before including in public inputs (Z007).
assert!(amount > 0 && amount <= MAX_WITHDRAW, "amount out of range");
// Bind to this transaction's context.
let recipient_hash = hash_address(&env, &recipient);
let contract_id_hash = hash_address(&env, &env.current_contract_address());
let public_inputs = vec![&env, recipient_hash, amount as u64, contract_id_hash];
verify_proof(&env, &proof, &public_inputs);
token_client.transfer(&env.current_contract_address(), &recipient, &amount);
}The verifying key is the cryptographic root of trust. Treat it like an admin key:
- Never hardcode it — store in governance-controlled persistent storage.
- Protect rotation — require admin
require_auth()before any update. - Verify integrity before use — hash-check the loaded key on every verification call.
Pattern:
pub fn set_verifying_key(env: Env, vk: BytesN<64>) {
let admin: Address = env.storage().persistent().get(&DataKey::Admin).unwrap();
admin.require_auth();
let hash = env.crypto().sha256(&Bytes::from_slice(&env, vk.as_ref()));
env.storage().persistent().set(&DataKey::VerifyingKey, &vk);
env.storage().persistent().set(&DataKey::VkHash, &hash);
}
pub fn verify(env: Env, proof: Vec<u8>, inputs: Vec<u64>) -> bool {
let vk: BytesN<64> = env.storage().persistent().get(&DataKey::VerifyingKey).unwrap();
let expected: BytesN<32> = env.storage().persistent().get(&DataKey::VkHash).unwrap();
assert_eq!(env.crypto().sha256(&Bytes::from_slice(&env, vk.as_ref())), expected);
groth16_verify(vk.as_ref(), &proof, &inputs)
}Rules covered: Z004, Z005, Z010
Always use a distinct, versioned domain tag when hashing for different commitment types. Prefix every hash call with a unique byte string that identifies the context.
fn commit_with_domain(env: &Env, domain: &[u8], value: &[u8]) -> BytesN<32> {
let mut input = Bytes::new(env);
input.extend_from_slice(domain);
input.extend_from_slice(value);
env.crypto().sha256(&input)
}
// Example usage
let nullifier_hash = commit_with_domain(&env, b"sanctifier:nullifier:v1", secret.as_ref());
let amount_hash = commit_with_domain(&env, b"sanctifier:amount:v1", &amount.to_be_bytes());
let recipient_hash = commit_with_domain(&env, b"sanctifier:recipient:v1", recipient_bytes.as_ref());Rules covered: Z011
When using Merkle trees for on-chain set commitments (whitelists, UTXOs, anonymity sets):
- Load the root from contract storage — never accept a caller-supplied root.
- Verify the proof before taking any action.
- Nullify the leaf after use to prevent double-claim (combine with Z001 pattern).
pub fn claim(env: Env, leaf: BytesN<32>, path: Vec<BytesN<32>>) {
let root: BytesN<32> = env.storage().persistent().get(&DataKey::MerkleRoot).unwrap();
assert!(verify_merkle_proof(&leaf, &path, &root), "not a member");
let spent_key = (symbol_short!("spent"), leaf.clone());
assert!(!env.storage().persistent().has(&spent_key), "already claimed");
env.storage().persistent().set(&spent_key, &true);
transfer_reward(&env);
}Rules covered: Z014
| Rule | Name | Severity | Category |
|---|---|---|---|
| Z001 | Missing Nullifier / Double-Spend Check | Critical | zk-proof-integrity |
| Z002 | Insecure or Predictable Randomness as Circuit Input | High | zk-randomness |
| Z003 | Missing Public-Input Binding (Proof Malleability) | Critical | zk-proof-integrity |
| Z004 | Hardcoded Trusted-Setup Parameters | Critical | zk-trusted-setup |
| Z005 | Missing Verifying-Key Integrity Check | High | zk-trusted-setup |
| Z006 | Missing Proof Nonce / Uniqueness Enforcement | High | zk-proof-integrity |
| Z007 | Under-Constrained Circuit Inputs | Critical | zk-circuit-constraints |
| Z008 | Curve / Field Mismatch | Critical | zk-circuit-constraints |
| Z009 | Unbounded Proof-Verification Loop | High | zk-resource |
| Z010 | Missing Access Control on Verifying-Key Rotation | Critical | zk-access-control |
| Z011 | Commitment Reuse Without Domain Separation | High | zk-cryptography |
| Z012 | ZK Property Leak via Public-Output Over-Exposure | Medium | zk-privacy |
| Z013 | Insufficient Batch-Validation in ZK-Rollup Transitions | Critical | zk-proof-integrity |
| Z014 | Missing Merkle-Root Inclusion-Proof Verification | Critical | zk-proof-integrity |
Critical rules (Z001, Z003, Z004, Z007, Z008, Z010, Z013, Z014) must be resolved before mainnet deployment. High rules should be resolved before public testnet exposure.
- Every proof-consuming entry point records a nullifier before transferring value (Z001)
- Proofs are bound to an epoch or nonce to prevent cross-period replay (Z006)
- Public inputs include recipient, amount, and contract ID (Z003)
- All public input field elements are range-validated before the verifier call (Z007)
- Verifying key is stored in governance-controlled persistent storage, not hardcoded (Z004)
- Verifying-key rotation is protected by
require_auth()on an admin/multisig (Z010) - Verifying-key integrity is hash-checked before every use (Z005)
- Curve/field identifier is validated at verifier entry (Z008)
- Blinding factors are generated off-chain by a secure RNG — no on-chain entropy (Z002)
- All commitment types use distinct, versioned domain separation tags (Z011)
- Public outputs do not include private witness values or full input vectors (Z012)
- Merkle inclusion proofs are verified against the contract's committed root, not a caller-supplied root (Z014)
- Batch/rollup proofs assert
old_root == current_rootbefore accepting a state transition (Z013) - Batch-proof loops are bounded by a protocol constant (Z009)
- All privileged functions have
require_auth()guards (S001) - Arithmetic operations are overflow-safe (S002)
- Storage keys avoid collisions (S004)
- Contract is protected against re-initialization (S008)
- 0xPARC ZK Bug Tracker — curated list of real-world ZK application bugs
- Trail of Bits: ZK Security
- Zellic: Common ZK vulnerabilities
- Noir language documentation
- Soroban authorization model
- Soroban fees and metering
- Sanctifier S-rule documentation — general Soroban security rules
- Sanctifier Security Checklist