Skip to main content
Version: 0.4.7

Getting Started

Set up the SDK, create an encrypted balance account, check your balance, and send a private transfer.

Prerequisites

  • Node.js 18+ or Bun
  • Testnet tokens in a standard EVM wallet

Get testnet tokens

A faucet at zkstables-faucet-base-sepolia.pages.dev dispenses 100 of each test token (zkUSD, zkEUR, zkGBP, zkPLN) on Base Sepolia per request. Pass the EVM address of the wallet you'll use as your controller — not your zk1... address.

curl -X POST https://zkstables-faucet-base-sepolia.pages.dev/api/mint \
-H 'content-type: application/json' \
-d '{"address":"0xYourControllerAddress"}'

Install

The SDK is published to GitHub Packages under the @cardinal-cryptography scope. GitHub Packages requires authentication for installs, so before bun add (or npm install) you need a .npmrc pointing the scope at the GitHub registry with a personal access token.

  1. Create a GitHub PAT with the read:packages scope.

  2. Add to ~/.npmrc (or a project-local .npmrc):

    @cardinal-cryptography:registry=https://npm.pkg.github.com
    //npm.pkg.github.com/:_authToken=YOUR_GITHUB_PAT
  3. Install:

    bun add @cardinal-cryptography/sdk viem

@cardinal-cryptography/sdk is dual-target: Node and browser are served by the same package via conditional exports. Node imports route to the Node entry; browser bundlers (Vite, webpack, etc.) route to the browser entry. You install one package regardless of where you run.

Browser setup

Building for the browser and generating proofs? The prover runs as multithreaded WebAssembly, which needs cross-origin isolation plus a couple of bundler settings. Node, React Native, and read-only / EVM-only browser apps (no proving) can skip this section.

Cross-origin isolation (COOP/COEP)

The prover's worker threads use SharedArrayBuffer, which a page only gets when it is cross-origin isolated. Without it the SDK silently falls back to single-threaded proving (much slower) and logs a warning. Enable isolation by serving your app with two response headers:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless

credentialless is the lenient choice — cross-origin no-cors subresources (third-party images, scripts) load without credentials instead of having to send a Cross-Origin-Resource-Policy header. The stricter alternative is Cross-Origin-Embedder-Policy: require-corp, which makes every cross-origin subresource opt in with its own CORP/CORS header. Both set crossOriginIsolated === true, which is all the prover checks.

The headers must be on the served responses — the Vite config below sets them on both the dev server and preview, so vite dev is isolated too. A raw vite dev without this header config gives no isolation; if your setup can't inject response headers in dev (some hosts apply them only to the production build), vite build + vite preview serves them reliably.

Bundler config (Vite)

// vite.config.ts
import { defineConfig } from 'vite'

const coiHeaders = {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'credentialless', // or 'require-corp' (stricter)
}

export default defineConfig({
server: { headers: coiHeaders },
preview: { headers: coiHeaders },
// The prover's wasm worker pool (wasm-bindgen-rayon) uses dynamic imports;
// Vite's default IIFE worker format can't bundle those.
worker: { format: 'es' },
// bb.js and the Noir packages ship wasm + workers that Vite's dependency
// pre-bundling (esbuild) can mishandle — exclude them if you hit wasm/worker
// load errors.
optimizeDeps: {
exclude: ['@aztec/bb.js', '@noir-lang/noir_js', '@noir-lang/acvm_js', '@noir-lang/noirc_abi'],
},
})

The proving wasm ships inside the package and is lazy-loaded on first use; your bundler emits it as an asset automatically. If you serve the .wasm yourself (custom server or CDN), send it with Content-Type: application/wasm. Other bundlers (webpack 5+, Rspack, Parcel 2, …) follow the same dynamic imports — the equivalents apply (isolation headers, ES-module workers, and excluding the wasm deps from pre-optimization).

First proof: prewarm

The first proof pays a one-time warmup — loading the circuit and initializing the prover (bb.js) — on top of the proof itself, and it's slowest single-threaded (no isolation). Move that cost off the user's first action by prewarming behind a splash/loading screen:

await runtime.prewarmProver() // load circuits + initialize the prover eagerly

With the React provider, pass prewarm: true (see React Hooks). This applies on every platform, not just React Native.

Create a runtime and account

The runtime provides two services that run locally on your machine:

  • Decryption -- reverses the on-chain encryption so you can read your balance
  • Proving -- generates zero-knowledge proofs that verify your transfers are valid without revealing amounts

An account holds your keys in memory and exposes decrypt, prove, and sign operations.

import { createRuntime, createFullClient } from '@cardinal-cryptography/sdk'
import { createPublicClient, http } from 'viem'
import { baseSepolia } from 'viem/chains'

const runtime = await createRuntime()

// Derives two keys from your mnemonic:
// - Encryption key (ESK): decrypts your balance, used as witness in ZK proofs
// - Controller key (CSK): authorizes transfers on-chain via EIP-712 signatures
const account = runtime.createAccountFromMnemonic('your mnemonic here')

console.log('Your ZK address:', account.zkpAddress) // zk1...

Your ZK address (zk1...) is your public identifier in the encrypted balance system. Share it with others so they can send you private transfers -- it doesn't reveal your keys or balance.

Create a client

The client connects your account to the chain. Gas is sponsored by default via a bundler and paymaster -- users don't need ETH.

const publicClient = createPublicClient({ chain: baseSepolia, transport: http() })

const client = await createFullClient({
client: publicClient,
zkpAccount: account,
})

The factory uses default bundler and paymaster endpoints. See Gas Sponsorship to customize these.

Register your EPK

The first chain interaction with a new account binds your ZK address to a controller EVM address. Every subsequent send (transfer, encrypted-to-public, ...) verifies the controller signature against this binding. One-time per (account, token).

await client.sendRegisterEpk({
token: 'zkUSD',
controller: account.controllerAddress,
})

Register before sharing your ZK address with anyone — your address can't receive tokens until the EPK is bound on-chain.

Check your balance

The SDK reads the encrypted balance from the contract, then decrypts it locally using your encryption key. No private data is sent to the network.

const balance = await client.getDecryptedBalance({ token: 'zkUSD' })
console.log(`Decrypted balance: ${balance}`)

Send an encrypted transfer

Send tokens privately to another ZK address. Under the hood, the SDK:

  1. Reads and decrypts your current balance
  2. Generates a ZK proof that the transfer is valid (the slowest step)
  3. Signs a controller authorization (EIP-712, similar to a permit signature)
  4. Submits the transaction
await client.sendEncryptedTransfer({
token: 'zkUSD',
amount: 50n,
to: 'zk1recipientAddress...',
})

Cleanup

When you're done, free the runtime resources:

runtime.destroy()

What's next?

  • Public Account -- send to encrypted addresses, register, auto-encrypt (no proving needed)
  • Read Balances -- decrypt balances and transaction history in detail
  • Encrypted Transfers -- step-by-step prepare/sign/send flow, progress callbacks
  • Account Creation -- create accounts from mnemonics, signatures, or external signers like MetaMask