UPP — Universal Private PoolConcepts

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:

CircuitPurposeInputs → Outputs
ShieldDeposit public tokensPublic balance → 1 note
TransferPrivate send1 note → 2 notes (recipient + change)
MergeConsolidate notes2 notes → 1 note
WithdrawExit pool1 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)

CircuitR1CS ConstraintsProving Key (zkey)Proving Time
ShieldN/A (no proof)Instant
Transfer38K148 MB~15-25s
Withdraw35K157 MB~15-20s
Merge47K294 MB~20s
MergeTransfer 2×248K314 MB~25-30s
JoinSplit 4×2106K709 MB~30-40s
MergeTransfer 4×294K708 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 DownloadProgress callbacks
  • Versioned cache — bumping CIRCUIT_VERSION in 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.

On this page