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:
Verify the mint is supported before building a transaction
Calling deposit / withdraw / create with an unsupported mint fails at the account-fetch stage witherror 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 autxoDataStore, 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:
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’suserCommitment. 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 inscan() 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
NullifierAlreadyBurntfailure 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 withgetUmbraRelayer({ 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 thenetwork parameter — you never pass it explicitly.
The master seed signs once per session
The first operation that needs the master seed (typicallyregister()) 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:
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 bydestinationAddress 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.