How it works
Three steps. No identity data touches the blockchain at any point.
The flow
- 1Publish a compliance policy. Commit each holder's opaque ID and policy attributes into a Merkle tree, then publish only the 32 byte root on chain. The blockchain never sees holder identities or the holder count.
- 2Verify a holder's eligibility. Generate a zero knowledge proof that a holder exists in the tree without revealing which one. The proof is 256 bytes.
- 3Check the proof on chain. The smart contract verifies the proof using a single BN254 pairing operation. A nullifier prevents the same check from being replayed. The contract returns a pass/fail code. No identity data is involved.
What the verifier sees vs. what stays private
- Visible on chain: the Merkle root (which policy version) and the nullifier (replay prevention ID). Nothing else.
- Hidden from the chain: holder attributes, the full holder set, which holder was verified, and who asked.
Integrate
Two calls. One to verify eligibility, one to check the proof on chain.
API
POST /api/generate-proof
{ "holder_id": "acc20240891", "policy_id": "reg-d-506c" }
POST /api/submit-verify
{ "proof": "...", "nullifier": "0x...", "policy_version": 3 }
Solidity (Base)
require( verifier.verifyMatch(anchor, epoch, pA, pB, pC, nullifier) == 0, "not eligible" );
Soroban / Rust (Stellar)
let code: u32 = client.verify_match(&anchor, &epoch, &proof, &nullifier); assert!(code == 0, "not eligible");
Threat model
- What the chain never sees: holder identities, raw attributes, the full holder set, or which holder was verified. Only a 32 byte commitment and a one time nullifier are published.
- What could leak: set size (number of leaves) and update frequency are visible as on chain activity. Mitigated by batching updates and padding the tree to a fixed size.
- Dictionary attacks: each leaf commits to an opaque holder ID and policy attributes. Brute forcing requires guessing all fields jointly. A per deployment salt can be added for additional protection.
Stack
- Circuit: Circom 2.2.2 + Groth16 on BN254 (6,552 constraints)
- Hashing: Poseidon (~250 constraints per hash, ZK native)
- Stellar contract: Soroban (Rust), ~6KB WASM, verifies via bn254_multi_pairing_check (~40M CPU instructions)
- EVM contract: Solidity 0.8.20, verifies via native BN254 precompiles (~250K gas)
- Server: Bun + Hono, server side proof generation (snarkjs)
- Chains: Stellar Testnet (Protocol 25) and Base Sepolia