ZK Circuits
The four core circuits that power UPP operations — Shield, Transfer, Merge, and Withdraw.
Design Status
These circuit specifications are under active development and may change. The implementation at our preview app represents one sample approach — not the final design.
Overview
Four circuits handle all UPP operations:
| Circuit | Purpose | Inputs → Outputs |
|---|---|---|
| Shield | Deposit public tokens | Public balance → 1 note |
| Transfer | Private send | 1 note → 2 notes (recipient + change) |
| Merge | Consolidate notes | 2 notes → 1 note |
| Withdraw | Exit pool | 1 note → Public balance |
Common Constraints
All circuits enforce:
Token consistency: commitment = Poseidon(amount, ownerHash, blinding, origin, token) where ownerHash = Poseidon(spendingSecret)
Amount conservation: Input amounts = Output amounts (no value creation or destruction)
Merkle membership: Spent notes must exist in the tree (Merkle proof inside the circuit)
Nullifier uniqueness: Each note produces a unique nullifier based on the spending key
Shield Circuit
Deposit public tokens into the pool.
Public inputs: commitment, token, amount
Private inputs: blinding, origin
Constraints:
commitment = Poseidon(amount, ownerHash, blinding, origin, token)
origin = msg.sender (enforced on-chain)No proof of prior note ownership — this is a fresh deposit. ASP membership is not checked at shield time.
Transfer Circuit
Send privately to another user. 1 input note, 2 output notes (recipient + change).
Public inputs: nullifier, newCommitments[2], merkleRoot, aspRoot
Private inputs: note, merkleProof, recipientPubKey, amounts[2]
Constraints:
Note exists in Merkle tree (inclusion proof)
Nullifier = Poseidon(commitment, spendingKey, leafIndex)
inputAmount = amounts[0] + amounts[1]
If origin NOT in ASP: recipient must be origin (restricted transfer)Merge Circuit
Combine two notes into one. The origin becomes the merger.
Public inputs: nullifiers[2], newCommitment, merkleRoot
Private inputs: notes[2], merkleProofs[2], newBlinding
Constraints:
Both notes exist in Merkle tree
Both nullifiers correctly computed
outputAmount = inputAmount[0] + inputAmount[1]
New origin = msg.sender (enforced on-chain, not in circuit)Merge Changes Origin
Merge is the only way to change origin. The merger vouches for the funds — similar to a bona fide purchaser in property law.
Withdrawal Circuit
Exit the pool to a public address.
Public inputs: nullifier, recipient, amount, token, merkleRoot, aspRoot
Private inputs: note, merkleProof, aspProof
Constraints:
Note exists in Merkle tree
Nullifier correctly computed
Amount matches note
ASP check (one of two paths):
recipient == origin (ragequit — always allowed), OR
origin ∈ ASP allowlist (normal withdrawal)ASP Compliance Flow
Performance (SNARK)
| Circuit | R1CS Constraints | Proving Key (zkey) | Proving Time |
|---|---|---|---|
| Shield | N/A (no proof) | — | Instant |
| Transfer | 38K | 148 MB | ~15-25s |
| Withdraw | 35K | 157 MB | ~15-20s |
| Merge | 47K | 294 MB | ~20s |
| MergeTransfer 2×2 | 48K | 314 MB | ~25-30s |
| JoinSplit 4×2 | 106K | 709 MB | ~30-40s |
| MergeTransfer 4×2 | 94K | 708 MB | ~30-40s |
Proof generation is client-side using snarkjs PLONK over BLS12-381 (128-bit security). On-chain verification uses EIP-2537 precompiles (~200K gas). Proofs run in a Web Worker to keep the UI responsive.
Circuit Artifacts & Caching
Circuit artifacts (.wasm witness generators and .zkey proving keys) are hosted on a CDN at circuits.upd.io and versioned by date (e.g., /20260330/). The SDK manages artifact lifecycle automatically:
- Lazy loading — artifacts are only downloaded when needed for a proof
- IndexedDB caching — downloaded artifacts are cached persistently in the browser
- Download progress — byte-level progress reporting via
DownloadProgresscallbacks - Versioned cache — bumping
CIRCUIT_VERSIONin the SDK invalidates old cache entries
import { CircuitArtifactCache, CIRCUIT_VERSION } from '@permissionless-technologies/upp-sdk/core'
// Check if transfer circuit is cached
const cache = CircuitArtifactCache.shared()
const isCached = await cache.isCached('transfer')
// Preload with progress reporting
await cache.preload('transfer', undefined, (progress) => {
console.log(`${progress.artifact}: ${progress.percent}%`)
})
// Or use the React hook
import { useCircuitCache } from '@permissionless-technologies/upp-sdk/react'
function CircuitManager() {
const { isCached, preload, downloadProgress, status } = useCircuitCache()
return (
<button onClick={() => preload('transfer')}>
{isCached('transfer') ? 'Cached ✓' : 'Download Transfer Circuit'}
</button>
)
}Configuration
By default, the SDK fetches artifacts from https://circuits.upd.io/{CIRCUIT_VERSION}/. Override this for local development:
# .env — use local circuit files during development
NEXT_PUBLIC_CIRCUIT_BASE_URL=/circuits/// Pass to hooks — omit circuitBaseUrl to use the default CDN
const { build } = usePoolTransfer({
poolAddress,
publicClient,
circuitBaseUrl: process.env.NEXT_PUBLIC_CIRCUIT_BASE_URL || undefined,
})For post-quantum proofs, see the STARK vault.