How ZK Login Works

A developer’s guide to zero-knowledge social authentication on Stellar/Soroban.

1. What is ZK Login?

The Problem

Blockchain accounts are controlled by cryptographic keys. Users must manage seed phrases — 12 or 24 random words that, if lost, mean permanent loss of funds. This is the single biggest UX barrier to blockchain adoption.

The Solution

ZK Login lets users authenticate with a familiar identity provider (Google, Apple, passkeys) and generate a zero-knowledge proof that can be verified on-chain. The blockchain never sees the user’s identity — only a cryptographic commitment that proves “someone who controls this Google account authorized this action.”

Key insight: The proof reveals NOTHING about who you are. It only proves that you could have produced it — which requires knowledge of the identity hash. An observer sees a commitment (a random-looking number), not an email address.

The Flow

1 Identity Google/Passkey
2 Hash SHA-256 → field
3 Attest Poseidon binding
4 Prove Groth16 (browser)
5 Verify Soroban contract

This is the same pattern as Sui zkLogin and Aptos Keyless, adapted for Stellar/Soroban. The key difference: our circuit is tiny (2,295 constraints vs ~100M) because we use an off-chain attestation model instead of verifying JWTs inside the circuit.

2. Identity Hashing

The first step converts a social identity into a BN254 field element — a number small enough to use inside a ZK circuit. The formula is:

SHA-256("{provider}:{email}:{userId}:verified:{emailVerified}") → truncate to 31 bytes → BN254 field element

Why 31 bytes?

ZK circuits operate over finite fields. The BN254 curve’s scalar field prime is:

p = 21888242871839275222246405745257275088548364400416034343698204186575808495617

This is approximately 2254. A full 32-byte SHA-256 hash can exceed this prime, which would cause silent modular reduction (the hash wraps around, creating collisions). Truncating to 31 bytes (248 bits) guarantees the value fits safely within the field.

Provider Domain Separation

Each identity provider uses a unique prefix to prevent cross-provider identity collisions:

  • gmail:user@gmail.com:107542...:verified:true — Google accounts
  • passkey:{credentialId} — WebAuthn passkeys
  • apple:user@icloud.com:001234...:verified:true — Apple (future)

Implementation

export async function computeIdentityHash(input: IdentityInput): Promise<string> {
  const prefix = PROVIDER_PREFIX[input.provider.toLowerCase()];
  const identityString =
    `${prefix}:${input.email}:${input.userId}:verified:${input.emailVerified}`;

  const data = new TextEncoder().encode(identityString);
  const hashBuffer = await crypto.subtle.digest('SHA-256', data);
  const hashArray = new Uint8Array(hashBuffer);

  return Array.from(hashArray)
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
}

// Truncate to 31 bytes (248 bits) for BN254 field safety
export function hexToFieldElement(hex: string): bigint {
  return BigInt('0x' + hex.slice(0, 62)); // 62 hex chars = 31 bytes
}
Cross-platform: This exact formula is implemented identically in TypeScript (web + React Native), Swift (iOS), Kotlin (Android), and Rust (contract). Any mismatch = proof failure.

3. Poseidon & Server Attestation

Why Poseidon?

Inside a ZK circuit, every operation becomes constraints. Standard hash functions are expensive:

Hash FunctionConstraintsUse Case
SHA-256~25,000General purpose
Keccak-256~150,000Ethereum
Poseidon~250ZK circuits (100x cheaper)

Poseidon is an algebraic hash function designed specifically for ZK circuits. It operates natively over the BN254 field, making it orders of magnitude cheaper than bit-oriented hash functions like SHA-256.

The Trust Model

The server validates the user’s OAuth token (or passkey credential) and creates an attestation — a Poseidon hash that binds the identity to a timestamp:

attestationHash = Poseidon(identityHash, timestamp, nonce)

This is the trust dependency. The server attests: “I verified this identity at this time.” This is the same model as Sui’s salt service and Aptos’s pepper service.

The server also publishes a public commitment so the circuit can verify attestations came from an authorized server:

serverPubCommitment = Poseidon(serverSecret, 1)

Implementation

import { poseidon2, poseidon3 } from 'poseidon-lite';

// Attestation: binds identity + timestamp + nonce
export function computeAttestationHash(
  identityHash: bigint,
  timestamp: bigint,
  nonce: bigint,
): bigint {
  return poseidon3([identityHash, timestamp, nonce]);
}

// Server commitment: proves server knowledge without revealing secret
export function computeServerPubCommitment(
  serverSecretField: bigint,
): bigint {
  return poseidon2([serverSecretField, 1n]);
}
Could the server lie? Yes — but a rogue server would produce attestations with a different serverPubCommitment, which is verified on-chain. The contract is initialized with a specific commitment. Wrong server = proof fails.

4. The ZK Circuit

The circuit is written in Circom and compiled to a Groth16 proving system on the BN254 curve. It has 2,295 constraints.

Inputs & Outputs

SignalVisibilityDescription
identityHashPrivateSHA-256 hash of social identity (field element)
attestationTimestampPrivateWhen server created the attestation
serverNoncePrivateRandom nonce from server
attestationHashPrivatePoseidon(identity, timestamp, nonce)
blindingPrivateRandom factor hiding identity in commitment
nullifierSecretPrivateSecret seed for replay prevention
currentTimestampPublicCurrent time (for freshness check)
maxAttestationAgePublicMax allowed attestation age (seconds)
serverPubCommitmentPublicAuthorized server’s public commitment
commitmentOutputPoseidon(identityHash, blinding)
nullifierHashOutputPoseidon(identityHash, nullifierSecret)

Circuit Logic (5 Steps)

Step 1: Verify attestation — Recompute Poseidon(identity, timestamp, nonce) and constrain it equals the provided attestation hash. This proves the server actually attested to this identity.

Step 2: Server binding — Compute Poseidon(attestationHash, serverPubCommitment). This constraint ensures the attestation is bound to the authorized server.

Step 3: Freshness check — Verify currentTimestamp - attestationTimestamp ≤ maxAttestationAge and that the attestation isn’t from the future. Prevents stale attestations from being reused.

Step 4: Commitment — Compute commitment = Poseidon(identityHash, blinding). The blinding factor hides the identity hash. Published on-chain as a public output.

Step 5: Nullifier — Compute nullifierHash = Poseidon(identityHash, nullifierSecret). The contract stores used nullifiers to prevent replay attacks. Same identity + same secret = same nullifier (deterministic).

Full Circuit Code

pragma circom 2.1.0;
include "circomlib/circuits/poseidon.circom";
include "circomlib/circuits/comparators.circom";

template IdentityAttestation() {
    // Private inputs (witness)
    signal input identityHash;
    signal input attestationTimestamp;
    signal input serverNonce;
    signal input attestationHash;
    signal input blinding;
    signal input nullifierSecret;

    // Public inputs
    signal input currentTimestamp;
    signal input maxAttestationAge;
    signal input serverPubCommitment;

    // Public outputs
    signal output commitment;
    signal output nullifierHash;

    // Step 1: Verify attestation hash
    component attestationVerify = Poseidon(3);
    attestationVerify.inputs[0] <== identityHash;
    attestationVerify.inputs[1] <== attestationTimestamp;
    attestationVerify.inputs[2] <== serverNonce;
    attestationVerify.out === attestationHash;

    // Step 2: Server binding
    component serverBinding = Poseidon(2);
    serverBinding.inputs[0] <== attestationHash;
    serverBinding.inputs[1] <== serverPubCommitment;
    signal serverBindingCheck;
    serverBindingCheck <== serverBinding.out;

    // Step 3: Freshness check
    signal timeDiff;
    timeDiff <== currentTimestamp - attestationTimestamp;
    component ageCheck = LessEqThan(64);
    ageCheck.in[0] <== timeDiff;
    ageCheck.in[1] <== maxAttestationAge;
    ageCheck.out === 1;

    component notFuture = LessEqThan(64);
    notFuture.in[0] <== attestationTimestamp;
    notFuture.in[1] <== currentTimestamp;
    notFuture.out === 1;

    // Step 4: Commitment (hides identity)
    component commitmentHash = Poseidon(2);
    commitmentHash.inputs[0] <== identityHash;
    commitmentHash.inputs[1] <== blinding;
    commitment <== commitmentHash.out;

    // Step 5: Nullifier (prevents replay)
    component nullifierCompute = Poseidon(2);
    nullifierCompute.inputs[0] <== identityHash;
    nullifierCompute.inputs[1] <== nullifierSecret;
    nullifierHash <== nullifierCompute.out;
}

component main {public [
    currentTimestamp, maxAttestationAge, serverPubCommitment
]} = IdentityAttestation();

5. Groth16 Proof System

Groth16 is a non-interactive zero-knowledge proof system. Given a circuit (set of constraints), it produces a proof that the prover knows a valid witness (private inputs) satisfying all constraints, without revealing the witness.

Key Properties

  • Constant-size proof: Always 3 elliptic curve points (A, B, C) = 256 bytes, regardless of circuit size
  • Fast verification: One pairing check (~2ms in browser, ~10ms on Soroban)
  • Trusted setup: Requires a one-time ceremony to generate proving + verification keys
  • Curve: BN254 (also called BN128) — same curve as Ethereum’s ecPairing precompile

The Verification Equation

A Groth16 verifier checks this bilinear pairing equation:

e(A, B) = e(α, β) · e(Σk vkk · xk, γ) · e(C, δ)

Where e is the bilinear pairing on BN254, A, B, C are the proof points, α, β, γ, δ are from the verification key, and xk are the public inputs. If the equation holds, the proof is valid.

Proof Encoding for Soroban

snarkjs outputs proof coordinates as decimal strings. These must be converted to big-endian bytes for the Soroban contract:

// G1 point: 64 bytes (X:32 || Y:32)
export function buildG1Point(point: string[]): Uint8Array {
  const x = decimalToBytes(point[0], 32);
  const y = decimalToBytes(point[1], 32);
  const result = new Uint8Array(64);
  result.set(x, 0);
  result.set(y, 32);
  return result;
}

// G2 point: 128 bytes (X_c1:32 || X_c0:32 || Y_c1:32 || Y_c0:32)
// Note: c1-before-c0 ordering for Soroban BN254 compatibility
export function buildG2Point(point: string[][]): Uint8Array {
  const x_c1 = decimalToBytes(point[0][1], 32);
  const x_c0 = decimalToBytes(point[0][0], 32);
  const y_c1 = decimalToBytes(point[1][1], 32);
  const y_c0 = decimalToBytes(point[1][0], 32);
  const result = new Uint8Array(128);
  result.set(x_c1, 0);  result.set(x_c0, 32);
  result.set(y_c1, 64); result.set(y_c0, 96);
  return result;
}

6. On-Chain Verification

Contract Architecture

identity-auth Orchestrator
groth16-verifier BN254 pairing check
merkle-tree Commitment storage

The identity-auth contract receives the proof and public inputs, delegates the actual Groth16 verification to groth16-verifier (which performs the real BN254 bilinear pairing check), then stores the nullifier to prevent replay.

The authorize() Function

pub fn authorize(
    env: Env,
    proof_a: BytesN<64>,   // G1 point
    proof_b: BytesN<128>,  // G2 point
    proof_c: BytesN<64>,   // G1 point
    request: AuthorizationRequest,
) -> Result<AuthorizationResult, IdentityAuthError> {
    // 1. Check nullifier hasn't been used (replay prevention)
    if Self::is_nullifier_used(env.clone(), request.nullifier_hash.clone()) {
        return Err(IdentityAuthError::NullifierAlreadyUsed);
    }

    // 2. Build public inputs for Groth16 verification
    let public_inputs = Self::build_public_inputs(env.clone(), &request)?;

    // 3. Cross-contract call to groth16-verifier
    let verifier_id: Address = env.storage().persistent()
        .get(&DataKey::VerifierId).unwrap();
    let is_valid: bool = env.invoke_contract(
        &verifier_id,
        &Symbol::new(&env, "verify"),
        args,
    );

    if !is_valid {
        return Err(IdentityAuthError::InvalidProof);
    }

    // 4. Mark nullifier as used (AFTER verification succeeds)
    env.storage().persistent()
        .set(&DataKey::Nullifier(request.nullifier_hash.clone()), &true);

    // 5. Emit authorization event
    env.events().publish(
        (Symbol::new(&env, "authorized"),),
        (request.commitment.clone(), request.nullifier_hash),
    );

    Ok(AuthorizationResult {
        success: true,
        commitment: request.commitment,
        merkle_index: count as u32,
    })
}
Critical ordering: The nullifier is marked as used after verification succeeds. If marked before and verification fails, the nullifier would be “burned” — the user could never retry with that nullifier secret.

What Goes On-Chain

DataOn-Chain?Purpose
Email / identityNever
Identity hashNeverHidden inside proof
CommitmentYesProves identity ownership (blinded)
Nullifier hashYesPrevents replay attacks
Proof (A, B, C)Yes (TX data)Verified then discarded
Verification resultYes (event)Authorization event emitted

7. Integration Guide

The ZK Login contracts are already deployed on Stellar testnet. You can integrate ZK social authentication into your own dApp without deploying anything — just call the existing contracts.

Deployed Contract IDs (Testnet)

ContractIDRole
identity-auth CABLST6SHB7F3LNQBFF3BSVNDAPCOMBU2PA5WDKQH4VSOTDA2VWWUVZB Orchestrator — submit proofs here
groth16-verifier CCCVOVIW5VS4MBYPB77IX2H4IXVOFJCMBD2APMVWDJDYZFX6DYEB3WZ4 BN254 pairing check
merkle-tree CBPU2ABXSPEJ5T4KB2ON2KJ24L6BHWF24Z5NELIAJ42ULC5VILMSZOHQ Commitment storage

Step 1: Get an Attestation

Your app authenticates the user (Google OAuth or WebAuthn passkey), then sends the identity token to the attestation server. The server verifies the token and returns a Poseidon attestation.

Your app — request attestation
// 1. User signs in with Google (you handle OAuth)
const idToken = "eyJhbGciOiJSUzI1NiIs..."; // from Google

// 2. Send to attestation server
const resp = await fetch('https://stellar-zklogin-demo.pages.dev/api/attestation/google', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ idToken }),
});
const attestation = await resp.json();

// attestation contains:
// {
//   identityHash:        "0x1a2b...",  // SHA-256 field element
//   attestationHash:     "0x3c4d...",  // Poseidon(identity, timestamp, nonce)
//   attestationTimestamp: 1709510400,
//   serverNonce:         "0x5e6f...",
//   serverPubCommitment: "0x1605...",
// }

Step 2: Generate a ZK Proof (Browser)

Load the circuit WASM and proving key in the browser, then generate a Groth16 proof. All private inputs stay on the client — only the proof and public signals leave.

Browser — generate proof with snarkjs
// Load snarkjs (UMD) in your HTML: <script src="snarkjs.min.js"></script>
// Or import from npm: import * as snarkjs from 'snarkjs';

// Circuit artifacts (download from this demo or build your own)
const wasmUrl = 'https://stellar-zklogin-demo.pages.dev/circuits/gmail_attestation.wasm';
const zkeyUrl = 'https://stellar-zklogin-demo.pages.dev/circuits/gmail_attestation.zkey';

// User-generated secrets (KEEP THESE — needed to prove identity later)
const blinding = BigInt('0x' + randomHex(31));       // hides identity in commitment
const nullifierSecret = BigInt('0x' + randomHex(31)); // replay prevention seed

const circuitInputs = {
  identityHash:        attestation.identityHash,
  attestationTimestamp: attestation.attestationTimestamp.toString(),
  serverNonce:         attestation.serverNonce,
  attestationHash:     attestation.attestationHash,
  blinding:            blinding.toString(),
  nullifierSecret:     nullifierSecret.toString(),
  currentTimestamp:     Math.floor(Date.now() / 1000).toString(),
  maxAttestationAge:   '86400',  // 24 hours
  serverPubCommitment: attestation.serverPubCommitment,
};

const { proof, publicSignals } = await snarkjs.groth16.fullProve(
  circuitInputs, wasmUrl, zkeyUrl
);

// publicSignals = [commitment, nullifierHash, currentTimestamp,
//                  maxAttestationAge, serverPubCommitment]

Step 3: Submit Proof On-Chain

Encode the proof and public inputs, then call identity-auth.authorize(). The contract verifies the Groth16 proof via cross-contract call and stores the nullifier.

Submit to Soroban — using @stellar/stellar-sdk
import { Contract, Server, TransactionBuilder, Networks, nativeToScVal }
  from '@stellar/stellar-sdk';

const IDENTITY_AUTH = 'CABLST6SHB7F3LNQBFF3BSVNDAPCOMBU2PA5WDKQH4VSOTDA2VWWUVZB';
const server = new Server('https://soroban-testnet.stellar.org');
const contract = new Contract(IDENTITY_AUTH);

// Encode proof points (decimal strings → big-endian bytes)
const proofA = buildG1Point(proof.pi_a);   // 64 bytes
const proofB = buildG2Point(proof.pi_b);   // 128 bytes
const proofC = buildG1Point(proof.pi_c);   // 64 bytes

// Build AuthorizationRequest struct
const request = {
  commitment:           decimalToU256(publicSignals[0]),
  max_attestation_age:  86400,
  nullifier_hash:       decimalToU256(publicSignals[1]),
  server_pub_commitment: decimalToU256(publicSignals[4]),
  timestamp:            Number(publicSignals[2]),
};

// Build and submit transaction
const tx = new TransactionBuilder(account, { fee: '1000000', networkPassphrase: Networks.TESTNET })
  .addOperation(contract.call('authorize', proofA, proofB, proofC, request))
  .setTimeout(60)
  .build();

const prepared = await server.prepareTransaction(tx);
prepared.sign(keypair);
const result = await server.sendTransaction(prepared);

Step 4: Check Authorization Status

After a successful proof, the commitment is stored on-chain. Your dApp can check if a user is authorized by calling is_nullifier_used() or reading events.

Check nullifier status
// Check if a specific nullifier has been used (proof was accepted)
const isUsed = await contract.call('is_nullifier_used', nullifierHash);

// Get total authorization count
const count = await contract.call('get_auth_count');

// Listen for authorization events
// Event topic: ["authorized"]
// Event data:  (commitment, nullifier_hash)
Encoding helpers: The functions buildG1Point(), buildG2Point(), and decimalToU256() are available in this project’s src/crypto/encoding.ts. You can copy them into your project or use the stellar-zklogin SDK package (coming soon).

Architecture for Your dApp

Your Frontend OAuth + snarkjs
Attestation API stellar-zklogin-demo
identity-auth Testnet contract

You use our attestation server and deployed contracts. Your frontend handles OAuth, generates proofs in the browser, and submits transactions. No server needed on your side.

Want full control? If you want to run your own attestation server and deploy your own contracts (different server secret, your own trusted setup), see the Self-Hosting Guide below.

8. Self-Hosting Guide

Deploy your own ZK Login infrastructure — your own attestation server, your own Soroban contracts, your own trusted setup. Full sovereignty over the trust model.

Prerequisites

  • Rust 1.74+ with wasm32v1-none target
  • Stellar CLI 23.4+brew install stellar/tap/stellar-cli
  • Node.js 18+ and npm
  • Circom 2.1.0+ — ZK circuit compiler
  • snarkjs 0.7+ — Groth16 toolkit
  • A funded Stellar testnet account (~100 XLM for deployment)

Step 1: Clone & Build Contracts

Terminal
# Clone the monorepo
git clone https://github.com/nobak-net/stellar-zklogin.git
cd stellar-zklogin

# Build all Soroban contracts
stellar contract build

# Output WASMs in target/wasm32v1-none/release/:
#   identity_auth.wasm        (orchestrator)
#   groth16_verifier.wasm     (BN254 pairing verifier)
#   merkle_tree.wasm          (commitment storage)
#   poseidon_hash.wasm        (primitives)
#   bn254_basics.wasm         (primitives)
#   commitment_scheme.wasm    (primitives)

# Run contract tests
cargo test --workspace

Step 2: Compile the ZK Circuit

Terminal
cd circuits/identity-attestation
npm install

# Compile circuit → R1CS + WASM + symbol file
./scripts/compile.sh

# Output: build/identity_attestation.r1cs
#         build/gmail_attestation_js/gmail_attestation.wasm
# Stats: ~2,295 constraints, BN254 curve

Step 3: Trusted Setup

Groth16 requires a trusted setup ceremony. This generates the proving key (used by clients to generate proofs) and the verification key (stored in the contract).

Terminal
# Run Powers of Tau + circuit-specific setup
./scripts/trusted-setup.sh

# Output:
#   keys/identity_attestation.zkey    — proving key (client needs this)
#   keys/verification_key.json        — verification key (for the contract)
#   keys/pot14_final.ptau             — Powers of Tau ceremony

# Export verification key to Soroban format
node scripts/export-vk-to-soroban.js

# Output: keys/verification_key_soroban.json
Trusted setup security: The randomness used in the ceremony is critical. In production, run a multi-party ceremony where at least one party is honest. For testing, the single-party setup generated by trusted-setup.sh is fine.

Step 4: Deploy to Stellar Testnet

Terminal
# Fund a testnet account (if you don't have one)
stellar keys generate my-deployer --network testnet --fund

# Deploy all contracts
./scripts/deploy-testnet.sh my-deployer

# This will:
# 1. Deploy groth16-verifier, merkle-tree, identity-auth
# 2. Initialize each contract (admin = your deployer)
# 3. Load the verification key into groth16-verifier
# 4. Save all contract IDs to deployment_ids.env

# Load the IDs into your shell
source deployment_ids.env
echo "Identity Auth: $IDENTITY_AUTH_ID"

Step 5: Initialize identity-auth

The identity-auth contract needs your server public commitment — a Poseidon hash of your attestation server’s secret. This binds the contract to your server.

Generate server commitment
// In Node.js:
const { poseidon2 } = require('poseidon-lite');

// Your attestation server secret (keep this safe!)
const serverSecret = 'your-secret-string';

// Convert to field element (same as identity hash: SHA-256 → 31 bytes)
const secretField = stringToFieldElement(serverSecret);

// Compute public commitment
const serverPubCommitment = poseidon2([secretField, 1n]);
console.log('Server commitment:', serverPubCommitment.toString());
Terminal — initialize the contract
stellar contract invoke   --id $IDENTITY_AUTH_ID   --network testnet   --source my-deployer   -- initialize   --admin "$(stellar keys address my-deployer)"   --config '{
    "server_pub_key": "<64-byte-hex>",
    "verifier_id": "'$GROTH16_VERIFIER_ID'",
    "merkle_tree_id": "'$MERKLE_TREE_ID'",
    "vk_hash": "<verification-key-hash>"
  }'

Step 6: Run the Attestation Server

The attestation server validates OAuth tokens and produces Poseidon attestations. The demo server runs on Cloudflare Pages, but you can run it anywhere.

Terminal
cd examples/demo

# Install dependencies
npm install

# Set environment variables
export SERVER_SECRET="your-secret-string"         # same as step 5
export SOROBAN_RPC_URL="https://soroban-testnet.stellar.org"
export STELLAR_NETWORK="testnet"
export GMAIL_AUTH_CONTRACT_ID="$IDENTITY_AUTH_ID"
export SPONSOR_SECRET_KEY="S..."               # funded testnet account
export GOOGLE_CLIENT_ID="xxx.apps.googleusercontent.com"

# Run locally (Wrangler dev server)
npm run dev
# Server starts at http://localhost:8788

# Or deploy to Cloudflare Pages
npm run deploy

Step 7: Point Your App at Your Infrastructure

Change two URLs in your frontend:

Your app — use your own server + contracts
// Instead of the demo server, use yours:
const ATTESTATION_URL = 'https://your-server.example.com/api/attestation/google';

// Instead of the demo contracts, use yours:
const IDENTITY_AUTH_ID = 'CABC...YOUR_CONTRACT...';

// Circuit artifacts stay the same (unless you modified the circuit)
// Host them on your CDN or use the demo ones

Deploy to Mainnet

When ready for production:

Terminal
# Deploy to mainnet (requires funded mainnet account, ~100 XLM)
./scripts/deploy-mainnet.sh my-mainnet-identity

# The script will ask for confirmation before deploying.
# Same flow as testnet but with:
#   --network mainnet
#   Real XLM for gas
#   A proper multi-party trusted setup
Mainnet checklist:
• Run a multi-party trusted setup ceremony (not single-party)
• Use a strong, randomly generated SERVER_SECRET
• Store sponsor secret key securely (e.g., Cloudflare Workers Secrets)
• Set up monitoring for contract events and attestation server health
• Consider rate-limiting the attestation endpoint

Contract API Reference

ContractKey Functions
identity-auth authorize(proof_a, proof_b, proof_c, request) — submit ZK proof
is_nullifier_used(hash) — check replay
get_auth_count() — total authorizations
is_authorized(commitment, merkle_proof, index) — verify membership
groth16-verifier verify(proof, public_inputs) — BN254 pairing check
set_verification_key(admin, vk) — one-time VK load (immutable after)
get_verification_key() — read stored VK
merkle-tree insert_leaf(caller, leaf) — add commitment
verify_proof(leaf, index, proof, root) — Merkle inclusion
get_root() — current tree root
is_known_root(root) — historical root check
Public inputs order (must match circuit declaration):
[commitment, nullifierHash, currentTimestamp, maxAttestationAge, serverPubCommitment]
Circuit outputs come first (commitment, nullifierHash), then public inputs in declaration order.
Try It Yourself →