UPP — Universal Private Pool

SDK Quickstart

Install the UPP SDK, look up the live Sepolia deployment, and run shield / transfer / withdraw — in under 10 minutes.

Pre-audit. Sepolia only.

UPP is pre-audit and only deployed to Sepolia. Read the Known Limitations page before doing anything beyond a friendly demo — permissionlessTokens is on, the M31 token-address collision attack is unmitigated, and several infrastructure dependencies (proof gateway, ASP service) are single-operator today.

1. Install

npm install @permissionless-technologies/upp-sdk viem wagmi

Peer deps: viem is required, wagmi + React are only needed for the React hooks.

2. Sepolia deployment

The SDK ships the live Sepolia addresses; resolve them by chain ID:

import { getDeploymentOrThrow } from '@permissionless-technologies/upp-sdk'

const deployment = getDeploymentOrThrow(11155111)
// {
//   UniversalPrivatePool, ASPRegistryHub, UPPSwapModule,
//   implementations: { … },
//   verifiers: { Transfer, Merge, Withdraw, JoinSplit, MergeTransfer2x2, MergeTransfer4x2 },
//   TestToken, TestToken2,
//   chainId: 11155111,
//   deployBlock: 10846101,
//   deployTimestamp,
// }

For non-TypeScript callers or anything that wants raw addresses, here's the same set (cohort deployed 2026-05-18 for the V4 octary rollout — pool proxy is stable across the V4-aware impl upgrade):

AnchorValue
deployBlock (scan from here)10846101
Chain ID11155111

STARK SP1 withdraw verifiers (V1 → V4 are all live alongside; the additive-upgrade pattern keeps every prior version registered):

VersionWithdraw verifierTransfer verifier
V1 (sp1 v3)0xe711b6f3C2e4669940E8EA1026B815d4EA3eB5eB0x018fDAF95041CB456856e8E094F14Aa7838CCA7F
V2 (sp1 v4)0x2707AEa32B8E03218CdC17299795ee7DE6918Bfa0xe74A40C4Fe4822227D26cd2Bc8a4B59B9cBc1eDa
V3 (sp1 v6, Track C)0x0c3422412658C21FdFa96612223FD0a1f5176B660x37Bc7E52DCff6BFd1b86d061eA32129814d7657A
V4 (octary tree)0x646eD1455f9863a48F8350181922fb533D511eE30x7A6764e31A4c1F704faAd4feDE2196A652a50ef5

The useStarkVerifierType(poolAddress) hook lists what's currently registered — use it to drive a verifier selector in the UI rather than hard-coding any one of the above.

Why deployBlock matters

The indexer scans from deployBlock forward to find encrypted notes. Starting earlier costs RPC time; starting later misses notes. The pool proxy is the same address as before the V4 upgrade, so use 10846101 even though the V4-aware impl is newer.

import {
  UPPAccountProvider, useUPPAccount,
  useShield, usePoolTransfer, useWithdraw,
} from '@permissionless-technologies/upp-sdk/react'
import {
  encodeShieldTxFromBuildData,
  encodeTransferTxFromBuildData,
  encodeWithdrawTxFromBuildData,
  getDeploymentOrThrow,
} from '@permissionless-technologies/upp-sdk'
import {
  useAccount, useChainId, usePublicClient,
  useSignTypedData, useWriteContract,
} from 'wagmi'
import { parseUnits } from 'viem'

const { UniversalPrivatePool: poolAddress, UPPSwapModule: swapModuleAddress, TestToken: UPD, deployBlock } =
  getDeploymentOrThrow(11155111)

function App({ children }: { children: React.ReactNode }) {
  const { address } = useAccount()
  const chainId = useChainId()
  const publicClient = usePublicClient()
  const { signTypedDataAsync } = useSignTypedData()

  return (
    <UPPAccountProvider
      ethAddress={address}
      chainId={chainId}
      publicClient={publicClient}
      signTypedData={signTypedDataAsync}
      indexerConfig={{ contractAddress: poolAddress, swapModuleAddress, fromBlock: deployBlock }}
      activeProofSystem="snark"
    >
      {children}
    </UPPAccountProvider>
  )
}

function PrivacyPanel() {
  const { address } = useAccount()
  const { writeContractAsync } = useWriteContract()
  const { isSetup, balance, addNote, markNoteSpent, connect } = useUPPAccount()

  const shield = useShield({ poolAddress, tokenAddress: UPD })
  const xfer = usePoolTransfer({ poolAddress, tokenAddress: UPD, publicClient: usePublicClient() })
  const wdraw = useWithdraw({ poolAddress, tokenAddress: UPD, publicClient: usePublicClient() })

  if (!isSetup) return <button onClick={() => connect()}>Unlock privacy layer</button>

  const onShield = async () => {
    const d = await shield.build({ amount: parseUnits('100', 18), originAddress: address! })
    // ERC20 approve to the pool is yours to do
    await writeContractAsync({ address: poolAddress, ...encodeShieldTxFromBuildData(d) })
    addNote(d.noteData.note)
  }

  const onTransfer = async (recipient: string) => {
    const d = await xfer.build({ amount: parseUnits('10', 18), recipient })
    await writeContractAsync({ address: poolAddress, ...encodeTransferTxFromBuildData(d) })
    markNoteSpent(d.spentNote.commitment)
    addNote(d.changeNoteData.note)
  }

  const onRagequit = async () => {
    const d = await wdraw.build({
      amount: parseUnits('50', 18),
      recipient: address!,
      isRagequit: true,
    })
    await writeContractAsync({ address: poolAddress, ...encodeWithdrawTxFromBuildData(d) })
    markNoteSpent(d.spentNote.commitment)
    if (d.changeNoteData) addNote(d.changeNoteData.note)
  }

  return (
    <div>
      <p>Private balance: {balance.toString()} (wei)</p>
      <button onClick={onShield}>Shield 100</button>
      <button onClick={() => onTransfer('0zk1…')}>Transfer 10</button>
      <button onClick={onRagequit}>Ragequit 50 (no ASP)</button>
    </div>
  )
}

That's the whole golden path. The hooks own the proof + encryption; your component owns the wallet write. See the React Hooks page for the full surface (usePrivateBalance, usePersonalASP, useCircuitCache, swap hooks, passkey login).

4. Non-React quickstart (encoders directly)

import {
  encodeShieldTx,
  createNote, encryptNote,
  generateStealthAddress,
  deriveDualKeysFromSignature,
  getDeploymentOrThrow,
} from '@permissionless-technologies/upp-sdk'

const { UniversalPrivatePool: poolAddress, TestToken: token } = getDeploymentOrThrow(11155111)

// 1. Derive keys from a one-shot EIP-712 signature (same input → same keys, every time)
const signature = await walletClient.signTypedData(EIP712_DOMAIN_AND_MESSAGE)
const { snark } = await deriveDualKeysFromSignature(signature)

// 2. Build a shielded note for self
const { stealthAddress, ephemeralPubKey } = generateStealthAddress(snark.metaAddress)
const note = createNote({ amount: 100n * 10n**18n, token, origin: depositor })
const encryptedNote = await encryptNote(note, ephemeralPubKey)

// 3. Encode + submit
const tx = encodeShieldTx({
  token: note.token,
  amount: note.amount,
  ownerHash: note.ownerHash,
  blinding: note.blinding,
  encryptedNote,
})
await walletClient.writeContract({ address: poolAddress, ...tx })

The same shape works for transfer and withdraw — see Transaction Encoders for the full encoder catalog (SNARK / STARK V3 / STARK V4, including the V4 commit/flush pair).

5. The current demo posture

This is a Sepolia demo, not a production deployment. A few specifics worth knowing before integrating:

permissionlessTokens = true

Any ERC20 can be shielded today. Combined with the M31 token-address collision finding, that's exploitable on the STARK rail. Mitigation for a production deployment is a single tx — setPermissionlessTokens(false) + addSupportedToken(<allowlist>), both gated by ADMIN_ROLE on the pool. We've left it on for the friendly-audience demo so anyone can pull from the TestStableToken faucet and try the round-trip.

Plan B-1 commit/flush is enabled by default

V4 transfers always go through transferSTARKV4Commit + flushPendingV4Inserts(1) because the atomic transferSTARKV4 exceeds every public RPC's 16.77M gas cap. The React hooks handle this transparently — for now the user has to click "Flush" once per output commitment. A background flusher service is on the roadmap.

All fees are zero

The FeeManager constructor doesn't seed defaults and no admin call has set any non-zero amount on Sepolia. Read the live amount with feeManager.feeAmount(kind) before assuming. The Fee System page documents the FeeKind enum and the EIP-3009 / relayer payment paths.

Single-operator infrastructure

  • prover.upd.io — STARK Groth16 wrapping happens on one operator-run gateway. Fault stalls STARK transfers.
  • asp-whitelist.upd.io — one ECS task, one operator key, publishes the ASP root.

Both are documented under Known Limitations §5 / §6. Roadmap is to open-source the gateway image and decentralize ASP root publishing.

6. Self-hosting the ASP service

If you don't want to depend on asp-whitelist.upd.io, the service ships as @permissionless-technologies/upc-asp-whitelist and runs against any RPC + a Subsquid event source:

import {
  startASPService,
  SubsquidEventSource,
  SanctionsGate,
} from '@permissionless-technologies/upc-asp-whitelist'
import { parseAbiItem } from 'viem'

const shieldedEvent = parseAbiItem(
  'event Shielded(address indexed token, address indexed depositor, bytes32 indexed commitment, uint256 leafIndex, bytes encryptedNote)'
)

startASPService({
  rpcUrl: process.env.RPC_URL!,
  registryAddress: '0xB71129021E6f1C45D206aa15233d9B1DcEfdeb97',
  operatorPrivateKey: process.env.OPERATOR_PRIVATE_KEY as `0x${string}`,
  chainId: 11155111,
  port: 3001,
  eventSource: new SubsquidEventSource({
    archive: 'https://v2.archive.subsquid.io/network/ethereum-sepolia',
    apiKey: process.env.SQD_KEY,            // ← required since mid-2026
    rpcUrl: process.env.RPC_URL!,
    watchAddress: '0xCDA138FFd4789670e0034aE30946b8873bb51740',
    event: shieldedEvent,
    addressTopicIndex: 2,
    deployBlock: 10846101n,
    finalityConfirmation: 2,
  }),
  gate: new SanctionsGate({ blocklist: [/* OFAC etc. */] }),
})

Subsquid v2 archives require an API key

Subsquid gated the v2 archive endpoints in mid-2026. Without SQD_KEY (or the SDK's own SQD_API_KEY fallback) the service can't catch up from deployBlock — it'll fall back to RPC-only and run very slowly. Get a key at app.subsquid.io and set it in the service environment.

The operatorPrivateKey becomes the on-chain signer for ASP root updates against the registry above. The SanctionsGate is a pluggable filter; swap in your own gate.allows(origin) implementation to enforce a different policy.

7. Next steps

On this page