UPP — Universal Private PoolSDK Reference

React Hooks

UPPAccountProvider, useUPPAccount, and the discrete operation hooks (useShield, usePoolTransfer, useWithdraw, useSwap) — the React layer that wraps the encoders, sync engine, and crypto into a familiar hook surface.

The React hooks compose the entire SDK — key derivation, sync engine, encoders, proof workers, circuit cache — into a familiar hook surface. They live under @permissionless-technologies/upp-sdk/react and are designed to coexist with wagmi: you bring the wallet, they bring the privacy layer.

Hook surface at a glance

UPPAccountProvidermasterKeys · NoteStore · createSyncEngineuseUPPAccount()notes · balance · connect · syncNotesuseShieldbuild → ShieldBuildDatausePoolTransferbuild → PoolTransferBuildDatauseWithdrawbuild → WithdrawBuildDatauseSwapbuildPlace/Fill/Claim/CancelusePrivateBalancebalances per token + railuseCircuitCachepreload, evict, progressusePersonalASPASP registration stateuseStarkVerifierTyperegistered verifier ids

The split matters: useUPPAccount is the state, the discrete hooks are the actions. Operation hooks read from the account context for keys and notes, then return a build() function that produces viem-compatible tx args — your component still calls writeContract itself. The build/submit separation keeps the SDK from owning your wallet client.

UPPAccountProvider

Wrap your app once near the wagmi provider:

import { UPPAccountProvider } from '@permissionless-technologies/upp-sdk/react'
import { useSignTypedData, usePublicClient, useAccount, useChainId } from 'wagmi'

function PrivacyLayer({ 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}
      activeProofSystem="snark"
      indexerConfig={{
        contractAddress: poolAddress,
        swapModuleAddress,
        fromBlock: deploymentBlock,
        liveSyncIntervalMs: 60_000,
      }}
      enablePasskey
    >
      {children}
    </UPPAccountProvider>
  )
}

Props summary:

PropPurpose
ethAddressWallet address (from wagmi useAccount). Drives the per-account storage prefix.
chainIdCurrent chain ID. Used in the chain-fingerprint check so accounts can't leak across networks.
publicClientViem PublicClient (from usePublicClient). Required for the indexer and chain-fingerprint guard.
signTypedDataWagmi's signTypedDataAsync. Used by connect() to derive master keys via EIP-712.
indexerConfigPool/swap addresses, fromBlock, live-sync interval. Without this the indexer can't start.
activeProofSystem'snark' or 'stark' — filters unspentNotes and balance to the rail you're showing in the UI.
enablePasskeyWires up the WebAuthn-PRF path so users can derive an account from a passkey instead of a wallet signature.

The provider lazy-loads the SDK on mount and pre-warms the Poseidon31 WASM in the background so the first connect() doesn't pay the ~800 KB fetch on the critical path.

useUPPAccount

The account context. Use it for state and lifecycle; use the discrete hooks below for operations.

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

const {
  // ─── Setup & lifecycle ─────────────────────────────────
  isSetup,                     // boolean — master keys derived
  cryptoReady,                 // boolean — SDK loaded
  isConnecting,                // boolean — connect() in flight
  connect,                     // (password?: string) => Promise<void>
  logout, disconnect,          // clear keys + storage
  passkeySupported,            // boolean — WebAuthn PRF available
  connectWithPasskey,          // (options?) => Promise<void>
  isConnectingPasskey,
  isPasskeyAccount,            // boolean — keys came from a passkey

  // ─── Identity ──────────────────────────────────────────
  masterKeys,                  // SNARK master keys, null if not set up
  starkMasterKeys,             // STARK master keys, null if not set up
  ownerHash,                   // hex string — SNARK public identifier
  stealthAddress,              // 0zk1… SNARK stealth address
  starkStealthAddress,         // 0zks1… STARK stealth address

  // ─── Notes ─────────────────────────────────────────────
  notes,                       // ShieldedNote[] — all notes
  unspentNotes,                // ShieldedNote[] — filtered by activeProofSystem
  balance,                     // bigint — sum of unspentNotes (filtered)
  addNote, markNoteSpent,      // manual edits (e.g. after a transfer)
  createNoteForSelf,           // for shield + change outputs
  createNoteForRecipient,      // for outbound transfers

  // ─── Sync ──────────────────────────────────────────────
  isSyncing, lastSyncedBlock,
  syncNotes,                   // (contractAddress, client) => Promise<number>
  startLiveSync, stopLiveSync, isLiveSyncing,
  getStarkStateLeaves,         // V3 ordered leaf stream
  getStarkStateLeavesV4,       // V4 ordered leaf stream

  // ─── Selection ─────────────────────────────────────────
  selectOptimalCircuit,        // picks 1x2 / 2x2 for a given amount
} = useUPPAccount()

Why no top-level shield/transfer/withdraw

Earlier drafts of this SDK exposed account.shield() / account.transfer() directly. They were removed because privacy ops need a build step (proof, encryption, contract args) that's distinct from the submit step — and the submit step belongs to your wallet client, not the SDK. The discrete hooks (useShield, usePoolTransfer, useWithdraw, useSwap) return a build() function; you compose that with viem's writeContract or wagmi's useWriteContract however your app already does writes.

Connecting

There are two derivation paths; both end with the same MasterKeys populated on the context.

// 1. Wallet signature (EIP-712)
const { connect, isConnecting } = useUPPAccount()
await connect()                          // signature with default domain
await connect('my-salt')                 // optional password salts the account

// 2. Passkey (WebAuthn PRF)
const { connectWithPasskey, passkeySupported } = useUPPAccount()
if (passkeySupported) {
  await connectWithPasskey({ create: true })          // new account
  await connectWithPasskey({ create: true, password: 'work' })  // sub-account
  await connectWithPasskey()                           // unlock existing
}

Same signature input → same keys, every time. The provider auto-loads previously created accounts from IndexedDB on mount.

Discrete operation hooks

Each operation hook is configured once with pool/token addresses, then returns a build() (or build*() for swaps) that takes operation-specific params and produces tx args. Your component is still in charge of calling writeContract, awaiting the receipt, and calling addNote / markNoteSpent on the account.

useShield

import { useShield } from '@permissionless-technologies/upp-sdk/react'
import { encodeShieldTxFromBuildData } from '@permissionless-technologies/upp-sdk'

const { build, isPending, stage, error } = useShield({
  poolAddress,
  tokenAddress: UPD_ADDRESS,
})

const handleShield = async () => {
  const data = await build({ amount: 100n * 10n**18n, originAddress: address })
  // ERC20 approve omitted for brevity
  await writeContractAsync({
    address: poolAddress,
    ...encodeShieldTxFromBuildData(data),
  })
  addNote(data.noteData.note)
}

usePoolTransfer

The SNARK 1-in-2-out transfer with ASP membership proof. STARK transfer is a separate hook surface — see the V4 commit/flush page.

import { usePoolTransfer } from '@permissionless-technologies/upp-sdk/react'
import { encodeTransferTxFromBuildData } from '@permissionless-technologies/upp-sdk'

const { build, isPending, stage, provingProgress, error } = usePoolTransfer({
  poolAddress,
  tokenAddress: UPD_ADDRESS,
  publicClient,
  aspId: DEMO_ASP_ID,
})

const data = await build({ amount, recipient: '0zk1…' })
await writeContractAsync({
  address: poolAddress,
  ...encodeTransferTxFromBuildData(data),
})
markNoteSpent(data.spentNote.commitment)
addNote(data.changeNoteData.note)

stage walks through 'preparing' → 'generating_proof' → 'submitting_tx' → 'confirming' → 'done'. provingProgress is non-null only during the proof stage and is what you bind to a PLONK progress bar.

useWithdraw

import { useWithdraw } from '@permissionless-technologies/upp-sdk/react'
import { encodeWithdrawTxFromBuildData } from '@permissionless-technologies/upp-sdk'

const { build } = useWithdraw({ poolAddress, tokenAddress, publicClient })

const data = await build({
  amount,
  recipient,
  aspId: 1n,
  aspRoot,
  aspPathElements,
  aspPathIndices,
  // OR isRagequit: true to bypass ASP (recipient must equal origin)
})

await writeContractAsync({ address: poolAddress, ...encodeWithdrawTxFromBuildData(data) })

useSwap

The order-book hook is split: useSwapOrderBook reads order state, useSwap builds the order-side actions.

import { useSwap, useSwapOrderBook } from '@permissionless-technologies/upp-sdk/react'

const { orders, isLoading } = useSwapOrderBook({
  swapModuleAddress, publicClient, sellToken, buyToken,
})

const { buildPlaceOrder, buildFillOrder, buildClaimOrder, buildCancelOrder, stage } = useSwap({
  poolAddress, publicClient,
})

Each build* returns the matching *BuildData shape — pass it to encodeSwapOrderTxFromBuildData / encodeSwapFillTxFromBuildData / etc. from the main SDK entry point.

Read-side hooks

usePrivateBalance

Per-token balances aggregated across both rails plus the swap module:

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

const { balances, isSyncing, sync } = usePrivateBalance({
  poolAddress,
  publicClient,
  walletAddress: address,
  swapModuleAddress,
  tokenList: [{ address: UPD_ADDRESS, symbol: 'UPD', decimals: 18 }],
})

balances.forEach(b => {
  // b.snark.balance + b.stark.balance + b.unclaimedSnark.balance = b.total
})

usePersonalASP

Reads the user's personal ASP registration state from the ASP Hub, used by the demo to drive the "approve yourself" flow:

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

const { aspInfo, isOriginApproved, getASPRootForOrigin } = usePersonalASP({
  aspHubAddress, publicClient, userAddress: address,
})

if (aspInfo?.isApproved) {
  // ready to transfer/withdraw
}

useCircuitCache

Circuit proving keys are large (148–709 MB) and live in IndexedDB once downloaded. Proof hooks (usePoolTransfer, useWithdraw, useSwap) hit this cache automatically; this hook is for letting users see/manage what's cached.

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

const { isCached, preload, downloadProgress, status, evict, evictAll, version } = useCircuitCache()

<button disabled={isCached('transfer')} onClick={() => preload('transfer')}>
  {isCached('transfer') ? 'Cached' : 'Pre-download Transfer (148 MB)'}
</button>
{downloadProgress && <p>{downloadProgress.circuitType}: {downloadProgress.percent}%</p>}

useStarkVerifierType

Lists the STARK withdraw verifier types currently registered on the pool:

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

const { verifierTypes } = useStarkVerifierType(poolAddress)
// → [{ id: 0x…, name: 'SP1_GROTH16_BN254_V3' }, { id: 0x…, name: 'DIRECT_STWO_V1' }, …]

Useful for a "which verifier do you want to prove against?" selector when both DIRECT_STWO_V1 (Solidity Stwo verifier) and SP1_GROTH16_BN254_V3 (SP1 zkVM) are live on the same pool. The additive-upgrade pattern means V1/V2/V3 stay deployed alongside each other.

useProofWorker

Off-main-thread PLONK proving via a Web Worker:

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

const { workerManager } = useProofWorker()
// pass workerManager into usePoolTransfer / useWithdraw / useSwap config

When provided, proof generation happens in the worker and the main thread stays responsive. Without it everything runs inline.

useUPPCrypto

Lazy-loaded crypto primitives (Poseidon, BabyJubJub) for any direct cryptographic work outside the hook surface. Most apps don't need this — use the encoders.

STARK accounts

Setting enablePasskey or calling connect() derives both SNARK and STARK keys from the same signature (or passkey PRF output). The two keysets are domain-separated so a leak on one rail doesn't compromise the other.

To make the React layer scan STARK notes alongside SNARK:

  1. The provider already passes starkOwnerHash and starkSecret to createSyncEngine once starkMasterKeys is set.
  2. Flip activeProofSystem to 'stark' to filter unspentNotes and balance to STARK-rail spendable notes.
  3. usePrivateBalance always returns both rails per token; no flag needed.

The getStarkStateLeaves() / getStarkStateLeavesV4() accessors on useUPPAccount return the global STARK leaf streams you need to build transfer/withdraw witnesses — see the V4 Octary State Tree page.

A complete shield+transfer component

import { useState } from 'react'
import { useAccount, usePublicClient, useWriteContract } from 'wagmi'
import { parseUnits } from 'viem'
import {
  useUPPAccount,
  useShield,
  usePoolTransfer,
} from '@permissionless-technologies/upp-sdk/react'
import {
  encodeShieldTxFromBuildData,
  encodeTransferTxFromBuildData,
} from '@permissionless-technologies/upp-sdk'

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

  const shield = useShield({ poolAddress, tokenAddress: UPD_ADDRESS })
  const xfer = usePoolTransfer({ poolAddress, tokenAddress: UPD_ADDRESS, publicClient })

  const [recipient, setRecipient] = useState('')

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

  return (
    <div>
      <p>Private balance: {balance.toString()} (wei)</p>

      <button
        disabled={shield.isPending}
        onClick={async () => {
          const d = await shield.build({ amount: parseUnits('100', 18), originAddress: address! })
          // ERC20 approve omitted
          await writeContractAsync({ address: poolAddress, ...encodeShieldTxFromBuildData(d) })
          addNote(d.noteData.note)
        }}
      >
        Shield 100 UPD
      </button>

      <input value={recipient} onChange={e => setRecipient(e.target.value)} placeholder="0zk1…" />
      <button
        disabled={xfer.isPending || !recipient}
        onClick={async () => {
          const d = await xfer.build({ amount: parseUnits('10', 18), recipient })
          await writeContractAsync({ address: poolAddress, ...encodeTransferTxFromBuildData(d) })
          markNoteSpent(d.spentNote.commitment)
          addNote(d.changeNoteData.note)
        }}
      >
        Transfer 10 UPD
      </button>

      {xfer.provingProgress && <p>{xfer.provingProgress.stage}: {xfer.provingProgress.message}</p>}
    </div>
  )
}

See also

On this page