This document provides a comprehensive overview of the Zero-Knowledge (ZK) proof system and circuit implementation for the ZK API Credits project.
The ZK API system enables privacy-preserving access to any external API service using Zero-Knowledge proofs, Rate-Limit Nullifiers (RLN), and Ethereum smart contracts. Users deposit ETH once and make thousands of anonymous API calls without revealing their identity or linking requests together.
Reference Implementation: Claude API integration is provided as a complete example.
RLN is a cryptographic primitive that prevents double-spending while preserving privacy:
- Nullifier: A unique identifier for each request:
nullifier = Poseidon(a)wherea = Poseidon(secretKey, ticketIndex) - Signal: A proof of authenticity:
y = secretKey + a * xwherex = Poseidon(message) - Double-Spend Detection: If the same
ticketIndexis reused with different messages, the secret key can be recovered algebraically
Each user has a secret key k and generates an identity commitment:
ID = Poseidon(k)
This commitment is stored in the Merkle tree anonymity set onchain, allowing users to prove membership without revealing their identity.
- Structure: 20 levels deep, supporting up to 1,048,576 identities
- Hash Function: Poseidon (ZK-friendly)
- Storage: Onchain root, off-chain tree construction
- Purpose: Enables privacy-preserving membership proofs
┌─────────────────────────────────────────────────────────────┐
│ Client Side │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Secret Key k │───▶│ ZK Prover │───▶│ Proof π_req │ │
│ │ Refund Tix │ │ (Circom) │ │ Nullifier │ │
│ └──────────────┘ └──────────────┘ │ Signal (x,y) │ │
│ └──────┬───────┘ │
└────────────────────────────────────────────────┼───────────┘
│
│ HTTPS
▼
┌─────────────────────────────────────────────────────────────┐
│ ZK API Server (NestJS) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 1. Nullifier Check (Double-spend detection) │ │
│ │ - NullifierStoreService │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 2. Proof Verification (Groth16 ZK-SNARK) │ │
│ │ - ProofVerifierService │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 3. Execute Claude API Request │ │
│ │ - Anthropic SDK │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 4. Calculate Cost in ETH │ │
│ │ - EthRateOracleService (Kraken API) │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 5. Issue Refund Ticket │ │
│ │ - RefundSignerService (EdDSA) │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
│ Web3 RPC
▼
┌─────────────────────────────────────────────────────────────┐
│ Ethereum Mainnet (Smart Contract) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ZkApiCredits.sol │ │
│ │ - deposit() : Add funds + ID commitment │ │
│ │ - withdraw() : Reclaim unused funds │ │
│ │ - redeemRefund() : Claim refund tickets │ │
│ │ - slashDoubleSpend(): Extract k, reward slasher │ │
│ │ - slashPolicy() : Burn policy stake │ │
│ │ - Merkle Tree : Identity anonymity set │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
The ZK proof system now supports cryptographically valid Groth16 SNARK verification using snarkjs.
Previous (Mock): Only validated proof JSON structure Current (Real): Full cryptographic verification with trusted setup
Key Changes:
- New SnarkjsProofService for real proof generation/verification
- Updated ProofVerifierService to use cryptographic verification
- Automated trusted setup script:
npm run setup:circuit - Falls back to mock mode if trusted setup not complete (dev-friendly)
Files:
- Test circuit: circuits/api_credit_proof_test.circom (~676 constraints)
- Production circuit: circuits/api_credit_proof.circom (~12K constraints)
File: circuits/api_credit_proof_test.circom
A simplified circuit for development and testing:
Inputs:
secretKey(private) - User's secret keyticketIndex(private) - Request ticket indexsignalX(public) - RLN signal X componentidCommitmentExpected(public) - Expected identity commitment
Outputs:
nullifier- Unique request nullifiersignalY- RLN signal Y componentidCommitment- Identity commitment
Performance:
- Constraints: ~676 non-linear
- Proving time: ~100-500ms
- Verification time: ~5-20ms
File: circuits/api_credit_proof.circom
The full circuit proves four key properties:
- Membership: User's identity commitment is in the Merkle tree
- Refund Summation: All refund tickets are valid (EdDSA signature verification)
- Solvency: User has sufficient balance:
(ticketIndex + 1) × maxCost ≤ initialDeposit + totalRefunds - RLN: Generates nullifier and signal for double-spend prevention
Circuit Parameters:
levels = 20: Merkle tree depth (1,048,576 capacity)maxRefunds = 100: Maximum refund tickets per proof
Inputs:
// Private inputs
signal input secretKey;
signal input pathElements[levels];
signal input pathIndices[levels];
signal input refundValues[maxRefunds];
signal input refundSignatures[maxRefunds][3]; // [R8x, R8y, S]
signal input ticketIndex;
// Public inputs
signal input merkleRoot;
signal input maxCost;
signal input initialDeposit;
signal input signalX;
signal input serverPubKeyX;
signal input serverPubKeyY;
// Public outputs
signal output nullifier;
signal output signalY;
signal output idCommitment;File: circuits/api_credit_proof_simple.circom
A stripped-down version for testing that omits EdDSA signature verification, focusing on core RLN and solvency checks.
File: contracts/src/ZkApiCredits.sol
Manages deposits, withdrawals, slashing, and the Merkle root.
Key Functions:
// Deposit ETH and join anonymity set
function deposit(bytes32 idCommitment) external payable
// Withdraw unused funds
function withdraw(bytes32 idCommitment, address payable recipient, bytes32 secretKey) external
// Redeem refund tickets onchain
function redeemRefund(
bytes32 nullifier,
uint256 value,
uint256 timestamp,
uint256[3] calldata signature
) external
// Slash double-spenders
function slashDoubleSpend(
bytes32 secretKey,
bytes32 nullifier,
Signal calldata signal1,
Signal calldata signal2
) external
// Slash policy violators (server-only)
function slashPolicyViolation(bytes32 nullifier, bytes32 idCommitment) external onlyOwner
// Check if nullifier has been used (double-spend or refund redemption)
function isNullifierUsed(bytes32 nullifier) external view returns (bool)Dual Staking:
- RLN Stake: Claimable by anyone who proves double-spending
- Policy Stake: Burned (not transferred) by server for ToS violations
| Service | Purpose | Location |
|---|---|---|
| ZkApiService | Main orchestrator for chat requests | src/zk-api/zk-api.service.ts |
| ProofGenService | RLN primitives (Poseidon, nullifier/signal generation) | src/zk-api/proof-gen.service.ts |
| ProofVerifierService | ZK proof verification | src/zk-api/proof-verifier.service.ts |
| ZKProofService | Full snarkjs integration for Groth16 proofs | src/zk-api/zkproof.service.ts |
| BlockchainService | Ethers.js contract interface, Merkle tree sync | src/zk-api/blockchain.service.ts |
| MerkleTreeService | Off-chain Merkle tree with Poseidon hash | src/zk-api/merkle-tree.service.ts |
| NullifierStoreService | Tracks used nullifiers (SQLite persistent storage) | src/zk-api/nullifier-store.service.ts |
| EthRateOracleService | Fetches ETH/USD rates from Kraken | src/zk-api/eth-rate-oracle.service.ts |
| RefundSignerService | Signs refund tickets with EdDSA (Babyjubjub + Poseidon) | src/zk-api/refund-signer.service.ts |
Submit anonymous Claude API request with ZK proof.
Request Body:
{
messages: Array<{ role: string; content: string }>;
proof: string; // Groth16 proof (JSON)
publicInputs: {
merkleRoot: string;
maxCost: string;
initialDeposit: string;
signalX: string;
nullifier: string;
signalY: string;
idCommitment: string;
};
model?: string; // claude-opus-4.6, claude-sonnet-4.6, claude-haiku-4.5
maxTokens?: number;
}Response:
{
response: string; // Claude API response
actualCost: string; // Actual cost in wei
refundTicket: {
nullifier: string;
value: string; // Refund amount in wei
timestamp: number;
signature: {
R8x: string;
R8y: string;
S: string;
}
};
usage: {
inputTokens: number;
outputTokens: number;
}
}Get server's EdDSA public key for refund signature verification.
Get current Merkle root from onchain contract.
// Client-side
const secretKey = generateRandomKey();
const idCommitment = poseidon([secretKey]);
// Onchain
await zkApiCredits.deposit(idCommitment, { value: parseEther('0.01') });// Generate proof
const proof = await generateProof({
secretKey,
merkleProof: await contract.getMerkleProof(idCommitment),
refundTickets: previousRefunds,
ticketIndex: nextIndex,
maxCost: parseEther('0.001')
});
// Compute RLN signal
const a = poseidon([secretKey, ticketIndex]);
const nullifier = poseidon([a]);
const x = poseidon([payload]);
const y = secretKey + a * x;
// Submit request
const response = await fetch('/zk-api/chat', {
method: 'POST',
body: JSON.stringify({
messages: [{ role: 'user', content: 'What does 苟全性命於亂世,不求聞達於諸侯。mean?' }],
proof,
publicInputs: { merkleRoot, maxCost, signalX: x, nullifier, signalY: y, ... }
})
});
// Store refund ticket
refundTickets.push(response.refundTicket);
ticketIndex++;If a user reuses the same ticketIndex with different messages:
// Server detects: same nullifier, different signal x
const signal1 = { x: x1, y: y1 };
const signal2 = { x: x2, y: y2 }; // x2 ≠ x1
// Extract secret key: k = (y1*x2 - y2*x1) / (x2 - x1)
const k = (y1 * x2 - y2 * x1) / (x2 - x1);
// Submit to smart contract
await zkApiCredits.slashDoubleSpend(k, nullifier, signal1, signal2);Used for all hash operations in the ZK circuit:
import { buildPoseidon } from 'circomlibjs';
const poseidon = await buildPoseidon();
const hash = poseidon([input1, input2, ...]);Used for refund ticket signing with circuit-compatible cryptography:
import { buildEddsa, buildBabyjub, buildPoseidon } from 'circomlibjs';
const eddsa = await buildEddsa();
const babyJub = await buildBabyjub();
const poseidon = await buildPoseidon();
// Generate keypair
const privateKey = Buffer.from(crypto.randomBytes(32));
const publicKey = eddsa.prv2pub(privateKey);
// Sign message with Poseidon hash
const message = poseidon([nullifier, value, timestamp]);
const signature = eddsa.signPoseidon(privateKey, babyJub.F.e(message));
// Signature contains: { R8: [R8x, R8y], S }
// Verify signature
const isValid = eddsa.verifyPoseidon(
babyJub.F.e(message),
signature,
publicKey
);Why Babyjubjub + Poseidon?
- Circuit Efficiency: SHA256 requires ~25,000 constraints. Babyjubjub EdDSA + Poseidon use only ~1,500 constraints
- ZK-Friendly: Designed specifically for ZK-SNARKs on the BN128 curve
- Compatible: Matches the
EdDSAVerifiercircuit from circomlib used in our ZK proofs
All operations occur in a finite field:
const F = poseidon.F;
// Convert to field element
const aF = F.e(a);
const bF = F.e(b);
// Perform operation
const result = F.add(aF, F.mul(bF, cF));
// Convert back to bigint
const output = F.toObject(result);| Model | Input ($/M tokens) | Output ($/M tokens) |
|---|---|---|
| claude-opus-4.6 | $5 | $25 |
| claude-sonnet-4.6 | $3 | $15 |
| claude-haiku-4.5 | $1 | $5 |
async function calculateCostInETH(
inputTokens: number,
outputTokens: number,
model: string
): Promise<bigint> {
const pricing = CLAUDE_PRICING[model];
const costUSD = (inputTokens / 1_000_000) * pricing.input
+ (outputTokens / 1_000_000) * pricing.output;
const ethUsdRate = await getEthUsdRate(); // From Kraken API
const costETH = costUSD / ethUsdRate;
return BigInt(Math.ceil(costETH * 1e18)); // Convert to wei
}Assuming ETH = $2,000:
| Scenario | Input Tokens | Output Tokens | Model | Cost (USD) | Cost (ETH) |
|---|---|---|---|---|---|
| Simple Q&A | 100 | 400 | Opus 4.6 | $0.0105 | 0.00000525 |
| Code Generation | 500 | 2000 | Sonnet 4.6 | $0.0465 | 0.00002325 |
| Document Analysis | 10,000 | 1,000 | Haiku 4.5 | $0.015 | 0.0000075 |
- Secret Key Protection: Users must never reveal their secret key
k - Signal Randomness: Each
signalXmust be cryptographically random - Nullifier Uniqueness: Each ticket index can only be used once
- Merkle Proof Freshness: Clients must use current onchain Merkle root
- Proof Replay: Nullifiers are tracked onchain to prevent replay attacks
- Server Accountability: Policy stake is burned (not claimed) to prevent profit from false bans
- ✅ Identity Privacy: Requests cannot be linked to identity commitment
- ✅ Request Unlinkability: Each request uses unique nullifier
- ✅ Balance Privacy: ZK proof hides actual balance
- ✅ Cryptographic Enforcement: No trusted parties required
- ✅ Anonymity Set: Users are indistinguishable within all depositors
# Run all tests
npm test
# Run specific test suite
npm test -- zk-api.service.spec.ts
# Run with coverage
npm test -- --coverageTest the full proof generation and verification flow:
npx ts-node scripts/test-proof-verification.tscd circuits
circom api_credit_proof.circom --r1cs --wasm --sym- ZK circuit design (Circom)
- Smart contract (Solidity)
- Backend services (NestJS)
- API endpoints
- Unit tests (267 tests passing)
- Documentation
- ETH/USD oracle integration
- Refund ticket signing (EdDSA with Babyjubjub + Poseidon)
- RLN cryptographic primitives
- Merkle tree service
- Blockchain service
- Anthropic SDK integration
- Complete trusted setup ceremony (Powers of Tau, proving/verification keys)
- Replace in-memory nullifier store with persistent database (SQLite)
- Implement proper EdDSA with Babyjubjub curve (circuit-compatible)
- Implement proper key management (HSM/KMS) for EdDSA signing key
- Add event listener for onchain Deposit events
- Deploy contract to testnet/mainnet
- Security audit (contract + circuit + backend)
- Rate limiting per IP/nullifier
- Monitoring and alerting for double-spend attempts
- Gas optimization
- MEV protection for slashing transactions
This implementation follows the original ZK API Credits proposal with key differences:
- Original: ZK-STARK (post-quantum secure, no trusted setup)
- Current: Groth16 (ZK-SNARK)
- Rationale: ~10-20x faster verification, ~400x smaller proofs, lower gas costs
- Trade-off: Requires trusted setup, not post-quantum
- Migration path: Can switch to STARKs/PLONK in v2
- Original: Single large circuit for all operations
- Current: Three domain-specific circuits
withdrawal.circom- Merkle membership + identity ownershiprefund_redemption.circom- EdDSA signature batch verificationdouble_spend_slashing.circom- RLN secret key extraction- Benefits: Smaller trusted setups, faster proving, modular upgrades
- Original: "Contract inserts ID into on-chain Merkle Tree"
- Current: Backend maintains tree, contract stores root
- Issue: Creates server dependency for withdrawals
- Planned: Implement onchain incremental Merkle tree
For production deployment, address these trust dependencies:
- Onchain Merkle tree - Users can withdraw without server
- Server key rotation - Update EdDSA public key with timelock
- Admin timelocks - Prevent instant parameter changes
- Emergency withdrawal - Automatic after server downtime period
See OVERVIEW.md for complete comparison.
- ZK API Credits Proposal - Davide Crapis & Vitalik Buterin (Original specification)
- Rate-Limit Nullifiers Documentation
- Circom Documentation
- SnarkJS
- Poseidon Hash
- Anthropic API Pricing
- Kraken API
GPL-3.0