Skip to main content
The Quickstart is the uninterrupted happy path. This page collects the rest — the things you need before shipping, and the gotchas that produce confusing failures if you don’t know about them. Skim it once; come back when something behaves unexpectedly.

Setup that breaks if wrong

Pin the SDK to an exact version

Pin @umbra-privacy/sdk to an exact version (no ^, no ~). The ZK assets are versioned in lockstep with the package; a floating version can resolve a manifest that points at a different circuit hash than the one your wallet’s master seed was set up for. The current published release is 5.0.0-rc.3. Note that the npm latest tag still points at 4.0.0, so an unpinned npm install @umbra-privacy/sdk installs v4 — always pin.

RPC transport: WebSocket vs HTTP polling

getUmbraClient confirms transactions and Arcium MPC callbacks over the WebSocket RPC by default. Many public RPCs (including api.devnet.solana.com) throttle or refuse subscriptions, which surfaces mid-transaction as “Failed to establish WebSocket subscription”. If you don’t have a WS-capable RPC, inject the HTTP-polling transport through the deps (2nd) arg of getUmbraClient:
import { getPollingTransactionForwarder } from "@umbra-privacy/sdk/solana";
import { getPollingComputationMonitor } from "@umbra-privacy/sdk/arcium";

const client = await getUmbraClient(config, {
  transactionForwarder: getPollingTransactionForwarder({ rpcUrl }),
  computationMonitor: getPollingComputationMonitor({ rpcUrl }),
});

Verify the mint is supported before building a transaction

Calling deposit / withdraw / create with an unsupported mint fails at the account-fetch stage with error 3012 (pool missing). Check relayer.getSupportedMints() at app boot and cache it for the session. See Supported Tokens.

Production hardening

Use a real signer, not an in-memory keypair

createInMemorySigner() is ephemeral — the keypair disappears when the process exits. For anything beyond local testing, use a browser wallet via createSignerFromWalletAccount, or createSignerFromPrivateKeyBytes with a persisted keypair. See Wallet Adapters.

Persistent stores for scanning

Without a utxoDataStore, every scan() re-iterates from genesis across every active tree — fine on devnet, crippling on mainnet. Wire the sharded browser stores through the deps (2nd) arg of getUmbraClient (they are readonly on the client and cannot be assigned after construction). The stores derive their encryption keys from the client’s master seed, so the client must exist first — a chicken-and-egg. Resolve it with a bootstrap client; a shared seed cache makes the wallet sign only once across both getUmbraClient calls:
import type { MasterSeed } from "@umbra-privacy/sdk/types";
import {
  createBrowserStorageBackend,
  createShardedUtxoDataStore,
  createShardedNullifierStore,
} from "@umbra-privacy/sdk/store-adapters";

let cachedSeed: MasterSeed | undefined;
const masterSeedStorage = {
  load: async () =>
    cachedSeed ? ({ exists: true, seed: cachedSeed } as const) : ({ exists: false } as const),
  store: async (seed: MasterSeed) => {
    cachedSeed = seed;
    return { success: true } as const;
  },
};

// 1. Bootstrap client — derives + caches the master seed.
const bootstrap = await getUmbraClient(config, { masterSeedStorage });

// 2. Build the sharded IndexedDB stores from the bootstrap client.
const backend = createBrowserStorageBackend();

// 3. Final client — stores wired via deps (seed loaded from cache, no re-sign).
const client = await getUmbraClient(config, {
  masterSeedStorage,
  utxoDataStore: await createShardedUtxoDataStore(bootstrap, backend),
  nullifierStore: await createShardedNullifierStore(bootstrap, backend),
});
On Node.js (no IndexedDB), either omit the stores (accepting genesis re-scans) or wire the non-persistent createInMemoryUtxoDataStore() / createInMemoryNullifierStore() (these take no client) in the deps.

Correctness pitfalls

Receiver-burnable requires a fully-registered recipient

Receiver-burnable creates encrypt the unlocker against the recipient’s userCommitment. The recipient must have completed all three registration sub-steps (account init, X25519 key, user commitment). If they haven’t, the create fails — fall back to a self-burnable create, where the sender stays the unlocker and the recipient needs zero on-chain state. See Registration prerequisites for the full pre-check rubric.

Strip already-burnt notes before burning

The scanner discovers notes by commitment, not nullifier, so notes you’ve already burnt keep reappearing in scan() results. The receiver-into-ETA burner groups same-destinationAddress notes into one transaction; if even one note in a chunk is already burnt, the whole transaction reverts with NullifierAlreadyBurnt (Anchor 28004) and none of the fresh notes in it land.
  • Track burnt note ids client-side (or check the on-chain nullifier set) and filter before burning.
  • On that failure the relayer returns an empty stealthPoolNoteIds, so key idempotent-success handling off your own input note ids — not the relayer’s list.
  • Treat a NullifierAlreadyBurnt failure as idempotent success.
  • For simple flows, burning one note per call sidesteps grouping entirely.

Good to know

The relayer is separate from the client

The relayer is not configured on the client. For burning, construct it separately with getUmbraRelayer({ apiEndpoint: "https://relayer.api.umbraprivacy.com" }) and pass it to the burner factory’s relayer dep. The dep’s submitBurn / pollBurnStatus / getRelayerAddress map onto the relayer client’s existing submitClaim / pollClaimStatus / getRelayerAddress via plain TypeScript aliases (BurnSubmitterFunction = ClaimSubmitterFunction). The wire protocol still calls the endpoint /v1/claims.

The program address auto-resolves

The Umbra program address differs between devnet and mainnet. The SDK resolves the correct address automatically from the network parameter — you never pass it explicitly.

The master seed signs once per session

The first operation that needs the master seed (typically register()) prompts the wallet to sign a deterministic consent message. Subsequent operations reuse the cached seed without re-prompting.

Branded types (U64, Address)

Amounts are U64 and addresses are Address — both branded. Quickstart snippets show plain bigint / string literals for readability, but strict TypeScript requires you to construct them:
import { createU64 } from "@umbra-privacy/sdk/types";
import { address } from "@solana/kit"; // add @solana/kit as a direct dependency

const amount = createU64({ value: 1_000_000n });
const mint = address("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
A bare bigint / string will not type-check against U64 / Address.

The ZK prover lives in /zk-prover

Creating or burning notes requires a zkProver dependency for Groth16 proof generation, imported from @umbra-privacy/sdk/zk-prover. Creator provers use the form get{ATA,ETA}IntoStealthPoolNoteCreatorProver; burner provers use getClaim{Self,Receiver}ClaimableUtxoInto{EncryptedBalance,PublicBalance}Prover (these spellings come from the wire protocol).

The scanner does not attach Merkle proofs

scan() returns notes without Merkle proofs. Proofs are fetched per batch at burn time so a single proof set is shared across an entire batch — far cheaper than one proof per note. Pass scanned notes directly to a burner; don’t call enrichWithMerkleProof yourself unless you’re building a custom burn pipeline.

scan() is incremental and returns already-burnt notes

With a persisted utxoDataStore, scan() returns only leaves discovered since the last cursor and advances the cursor to the tip — so a second scan() returns 0 new notes. Don’t treat one call’s result as your complete set: accumulate across calls, read back via utxoDataStore.query(), or drive the cursor with a watermark. (It also returns already-burnt notes — see Strip already-burnt notes.)

Burn batching

Pass an array of notes to a burner. The receiver-into-ETA burner groups them by destinationAddress and chunks to ≤4 per proof. The single-note burners (self-into-ETA, self-into-ATA) have MAX_STEALTH_POOL_NOTES_PER_PROOF = 1 and loop internally — you still pass an array.