Skip to main content

Prerequisites

  • Node.js 18+ or a modern browser
  • A Solana wallet (or a generated keypair for testing)
  • A Solana JSON-RPC URL (HTTP and WebSocket)

1. Install

The SDK is one package — the ZK prover, indexer client, relayer client, and Solana primitives are all subpath modules of @umbra-privacy/sdk. snarkjs is a required peer dependency (the SDK imports it for Groth16 proving) — install it alongside the SDK or the first import throws ERR_MODULE_NOT_FOUND.
pnpm add @umbra-privacy/sdk@5.0.0-rc.6 snarkjs
If you connect a browser wallet (Phantom/Solflare/…) you also need the @wallet-standard/core peer dependency. It is not required for the createInMemorySigner / createSignerFromPrivateKeyBytes paths used below.
Pin the SDK to an exact version (no ^, no ~). The ZK assets are versioned in lockstep with the package; floating versions can resolve a manifest that points at the wrong circuit hash.

2. Create a Signer

For quick testing, generate an in-memory keypair. For anything reusable, load a persisted keypair with createSignerFromPrivateKeyBytes (it accepts a 64-byte keypair or a 32-byte seed). For production, use a wallet adapter — see Wallet Adapters.
import {
  createInMemorySigner,
  createSignerFromPrivateKeyBytes,
} from "@umbra-privacy/sdk";

// Ephemeral — disappears when the process exits. Test/dev only.
const signer = await createInMemorySigner();

// Reusable — pass your own secret bytes (e.g. from a Solana CLI keypair file).
// const secret = Uint8Array.from(JSON.parse(readFileSync("keypair.json", "utf8")));
// const signer = await createSignerFromPrivateKeyBytes(secret);

console.log("Wallet address:", signer.address);
Both factories are async — you must await them, or signer.address is undefined.

3. Create the Umbra Client

The client owns your signer, network configuration, and indexer endpoint. The relayer is not a client argument — you build it separately with getUmbraRelayer (step 9).
import { getUmbraClient } from "@umbra-privacy/sdk";

const client = await getUmbraClient({
  signer,
  network: "mainnet",
  rpcUrl: "https://api.mainnet-beta.solana.com",
  rpcSubscriptionsUrl: "wss://api.mainnet-beta.solana.com",
  indexerApiEndpoint: "https://utxo-indexer.api.umbraprivacy.com",
  // NOTE: there is NO relayerApiEndpoint here — see step 9.
});
  • Testing on devnet? Use network: "devnet", rpcUrl: "https://api.devnet.solana.com" (+ wss://api.devnet.solana.com), indexerApiEndpoint: "https://utxo-indexer.api-devnet.umbraprivacy.com", the devnet relayer https://relayer.api-devnet.umbraprivacy.com, and the devnet dUSDC mint 4oG4sjmopf5MzvTHLE8rpVJ2uyczxfsw2K84SUTpNDx7. See Supported Tokens for the full per-network mint list.
  • The Umbra program address differs between devnet and mainnet. The SDK resolves the correct address automatically based on the network parameter.
  • 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.
  • Browser: persist scan state. On Node you can omit persistent stores and the scanner falls back to in-memory state (fine for scripts). In the browser, wire createShardedUtxoDataStore + createShardedNullifierStore (@umbra-privacy/sdk/store-adapters) — otherwise every scan() walks every tree from genesis. Because those stores derive their keys from the client’s master seed, it’s a two-phase build: construct a bootstrap client, build the stores from it, then rebuild the client with the stores in deps. See the example app’s lib/umbra-client.ts for the full pattern.

4. Register Your Account

Registration sets up your on-chain Umbra identity. It is idempotent: skipped sub-steps cost zero SOL, so you can call it safely on every app start. Anonymous registration runs a Groth16 proof, so it requires a zkProver in the factory’s deps — omitting it throws ZK prover is required for anonymous-mode registration.
import { getUserRegistrationFunction } from "@umbra-privacy/sdk/registration";
import { getUserRegistrationProver } from "@umbra-privacy/sdk/zk-prover";

const register = getUserRegistrationFunction(
  { client },
  { zkProver: getUserRegistrationProver() },
);

const signatures = await register({
  confidential: true, // register the X25519 key (Shared-mode balances + receiver-burnable notes)
  anonymous: true,    // register the user commitment (anonymous mixer transfers) — needs the zkProver above
});

console.log(`Registered in ${signatures.length} transaction(s)`);
Internally registration is two independent on-chain instructions:
  • register_user_for_confidential_usage — synchronous; initialises the EncryptedUserAccount and stores the X25519 token-encryption pubkey.
  • register_user_for_anonymous_usage_v18 — MPC + ZK; stages master-viewing-key material, runs Fiat-Shamir + Groth16, queues the Arcium MPC computation, and stores the on-chain user commitment.
Both init_if_needed the user account, so either may run first. The orchestrator picks the right combination based on requested flags and on-chain state.

5. Deposit Tokens (ATA → ETA)

Shield an SPL or Token-2022 balance by moving it from your public Associated Token Account into your Encrypted Token Account. Amounts are branded U64 and mints are branded Address — wrap raw values with createU64 (@umbra-privacy/sdk/types) and address (@solana/kit) so the calls typecheck.
import { getATAIntoETADirectDepositorFunction } from "@umbra-privacy/sdk/deposit";
import { createU64 } from "@umbra-privacy/sdk/types";
import { address } from "@solana/kit";

const deposit = getATAIntoETADirectDepositorFunction({ client });

const MINT = address("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); // USDC (mainnet)
const amount = createU64({ value: 1_000_000n }); // 1 USDC (6 decimals)

const result = await deposit(
  signer.address, // credit your own ETA
  MINT,
  amount,
);

console.log("Deposit queued:", result.queueSignature);
if (result.callback?.status === "finalized") {
  console.log("Callback confirmed:", result.callback.signature);
}

6. Withdraw Tokens (ETA → ATA)

Move tokens back from your Encrypted Token Account to your public ATA. (MINT and amount are the branded values from step 5.)
import { getETAIntoATAWithdrawerFunction } from "@umbra-privacy/sdk/withdrawal";

const withdraw = getETAIntoATAWithdrawerFunction({ client });

const result = await withdraw(
  signer.address, // destination ATA owner
  MINT,
  amount,
);

console.log("Withdraw queued:", result.queueSignature);
if (result.callback?.status === "finalized") {
  console.log("Callback confirmed:", result.callback.signature);
}

7. Create a Receiver-Burnable Stealth Pool Note

Send tokens privately to a recipient by writing a Stealth Pool Note into the mixer. The recipient can later scan and burn it with no on-chain link to you as the sender.
Creating a note requires a zkProver dependency for Groth16 proof generation. The prover ships inside the SDK at @umbra-privacy/sdk/zk-prover — there is no separate @umbra-privacy/web-zk-prover package any more.
Receiver-burnable creates encrypt the unlocker against the recipient’s userCommitment, so the recipient must have completed all three registration sub-steps (account init, X25519 key, user commitment). If they haven’t, fall back to a self-burnable create with getATAIntoSelfBurnableStealthPoolNoteCreatorFunction — the sender stays the unlocker and the recipient needs zero on-chain state. Note this is “park then hand off”, not a direct credit: the funds remain spendable by you until you share the note’s unlocking material out-of-band, so it suits one-shot transfers to someone you can reach off-chain. See Stealth Pool Notes — Registration prerequisites for the full pre-check rubric.
import { getATAIntoReceiverBurnableStealthPoolNoteCreatorFunction } from "@umbra-privacy/sdk/deposit";
import { getATAIntoStealthPoolNoteCreatorProver } from "@umbra-privacy/sdk/zk-prover";
import { address } from "@solana/kit";

const zkProver = getATAIntoStealthPoolNoteCreatorProver();

const createNote = getATAIntoReceiverBurnableStealthPoolNoteCreatorFunction(
  { client },
  { zkProver },
);

const RECIPIENT = address("RecipientWalletAddressHere");

const signatures = await createNote({
  destinationAddress: RECIPIENT,
  mint: MINT,
  amount,
});
console.log("Stealth Pool Note created:", signatures[0]);
Spending from your shielded balance. The factory above sources tokens from your public ATA. To create a note from your existing Encrypted Token Account balance (the common case once you’ve deposited), use the ETA-sourced creators — getETAIntoReceiverBurnableStealthPoolNoteCreatorFunction / getETAIntoSelfBurnableStealthPoolNoteCreatorFunction — same call shape, paired with getETAIntoStealthPoolNoteCreatorProver.

8. Scan for Burnable Notes

As the recipient, run the scanner. It walks every active stealth-pool tree, decrypts every commitment it can with your viewing keys, and groups the results by (burn kind, source). Cursor state lives in client.utxoDataStore — the scanner picks up exactly where the last call left off.
import { getBurnableStealthPoolNoteScannerFunction } from "@umbra-privacy/sdk/burn";

const scan = getBurnableStealthPoolNoteScannerFunction({ client });

// Zero-arg. No (treeIndex, start, end) — the cursor is managed by the SDK.
const result = await scan();

console.log("Self-burnable from ETA: ", result.etaToStealthPoolSelfBurnable.length);
console.log("Receiver-burnable from ETA: ", result.etaToStealthPoolReceiverBurnable.length);
console.log("Self-burnable from ATA: ", result.ataToStealthPoolSelfBurnable.length);
console.log("Receiver-burnable from ATA: ", result.ataToStealthPoolReceiverBurnable.length);
console.log("Scanned trees:", result.scannedTrees.length);
The scanner does not attach Merkle proofs to the returned notes. Proofs are fetched per batch at burn time so a single proof set is shared across an entire batch — far cheaper than fetching one proof per note. Pass the scanned notes (with a kind tag — see step 9) directly to a burner factory; do not call enrichWithMerkleProof yourself unless you are building a custom burn pipeline.

9. Burn the Note

Burning consumes a note, reveals its nullifier on-chain to prevent double-spend, and credits the destination balance. We’ll burn the receiver-burnable note we just created, into the recipient’s Encrypted Token Account. The burner factory needs three dependencies: a fetchBatchMerkleProof (already exposed on client), a zkProver for the burn circuit, and a relayer adapter whose three methods (submitBurn, pollBurnStatus, getRelayerAddress) forward to the relayer service. The scanner returns notes without a kind discriminator, but the burner input is typed DecryptedStealthPoolNoteData & { kind: "receiver-burnable" } — tag each note with kind before passing it in.
import {
  getReceiverBurnableStealthPoolNoteIntoETABurnerFunction,
} from "@umbra-privacy/sdk/burn";
import { getUmbraRelayer } from "@umbra-privacy/sdk";
import { getClaimReceiverClaimableUtxoIntoEncryptedBalanceProver } from "@umbra-privacy/sdk/zk-prover";

const zkProver = getClaimReceiverClaimableUtxoIntoEncryptedBalanceProver();
const r        = getUmbraRelayer({ apiEndpoint: "https://relayer.api.umbraprivacy.com" });

const burn = getReceiverBurnableStealthPoolNoteIntoETABurnerFunction(
  { client },
  {
    fetchBatchMerkleProof: client.fetchBatchMerkleProof!,
    zkProver,
    relayer: {
      submitBurn:        r.submitClaim,        // alias: BurnSubmitter ≡ ClaimSubmitter
      pollBurnStatus:    r.pollClaimStatus,    // alias: BurnStatusPoller ≡ ClaimStatusPoller
      getRelayerAddress: r.getRelayerAddress,
    },
  },
);

// Tag the scanned notes with their burn kind (the scanner does not).
const notes = result.etaToStealthPoolReceiverBurnable.map(
  (n) => ({ ...n, kind: "receiver-burnable" as const }),
);
if (notes.length > 0) {
  const out = await burn([notes[0]]);
  for (const [batchIndex, batch] of out.batches) {
    console.log(
      "Batch",   batchIndex,
      "status:", batch.status,
      "id:",     batch.requestId,
      "sig:",    batch.callbackSignature ?? batch.txSignature,
    );
  }
}
For self-burnable notes use the matching burners + provers: getSelfBurnableStealthPoolNoteIntoETABurnerFunction / getSelfBurnableStealthPoolNoteIntoATABurnerFunction, with getClaimSelfClaimableUtxoIntoEncryptedBalanceProver / getClaimSelfClaimableUtxoIntoPublicBalanceProver. Tag those notes with kind: "self-burnable".
The submitBurn / pollBurnStatus property names map onto the relayer client’s existing submitClaim / pollClaimStatus via plain TypeScript aliases (BurnSubmitterFunction = ClaimSubmitterFunction). The wire protocol still calls the endpoint /v1/claims — nothing has changed there.
Batching: pass an array of notes. The receiver-into-ETA burner groups them by destinationAddress, chunks to ≤5 per proof, and runs each chunk through the relayer. Single-note burners (self-into-ETA, self-into-ATA) have MAX_NOTES_PER_PROOF = 1 and loop internally — caller still passes an array.

Full Example

A runnable Node script (uses an ephemeral signer, so fund + reuse a real keypair to take it past register). Stores are omitted — on Node the scanner falls back to in-memory state; for browser persistence see the two-phase note in step 3.
import {
  createInMemorySigner,
  getUmbraClient,
  getUmbraRelayer,
} from "@umbra-privacy/sdk";
import { createU64 } from "@umbra-privacy/sdk/types";
import { address } from "@solana/kit";
import { getUserRegistrationFunction } from "@umbra-privacy/sdk/registration";
import {
  getATAIntoETADirectDepositorFunction,
  getATAIntoReceiverBurnableStealthPoolNoteCreatorFunction,
} from "@umbra-privacy/sdk/deposit";
import { getETAIntoATAWithdrawerFunction } from "@umbra-privacy/sdk/withdrawal";
import {
  getBurnableStealthPoolNoteScannerFunction,
  getReceiverBurnableStealthPoolNoteIntoETABurnerFunction,
} from "@umbra-privacy/sdk/burn";
import {
  getUserRegistrationProver,
  getATAIntoStealthPoolNoteCreatorProver,
  getClaimReceiverClaimableUtxoIntoEncryptedBalanceProver,
} from "@umbra-privacy/sdk/zk-prover";

async function main() {
  // 1. Signer (use your wallet adapter / a persisted keypair in production).
  const signer = await createInMemorySigner();

  // 2. Client (relayer is built separately, below).
  const client = await getUmbraClient({
    signer,
    network: "mainnet",
    rpcUrl: "https://api.mainnet-beta.solana.com",
    rpcSubscriptionsUrl: "wss://api.mainnet-beta.solana.com",
    indexerApiEndpoint: "https://utxo-indexer.api.umbraprivacy.com",
  });

  const MINT = address("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); // USDC

  // 3. Register (idempotent; anonymous mode needs the registration prover).
  const register = getUserRegistrationFunction(
    { client },
    { zkProver: getUserRegistrationProver() },
  );
  await register({ confidential: true, anonymous: true });

  // 4. Deposit (ATA → ETA).
  const deposit = getATAIntoETADirectDepositorFunction({ client });
  await deposit(signer.address, MINT, createU64({ value: 1_000_000n }));

  // 5. Withdraw (ETA → ATA).
  const withdraw = getETAIntoATAWithdrawerFunction({ client });
  await withdraw(signer.address, MINT, createU64({ value: 1_000_000n }));

  // 6. ZK provers (Groth16).
  const createProver = getATAIntoStealthPoolNoteCreatorProver();
  const burnProver   = getClaimReceiverClaimableUtxoIntoEncryptedBalanceProver();

  // 7. Create a receiver-burnable Stealth Pool Note.
  const createNote = getATAIntoReceiverBurnableStealthPoolNoteCreatorFunction(
    { client },
    { zkProver: createProver },
  );
  const RECIPIENT = address("RecipientWalletAddressHere");
  await createNote({ destinationAddress: RECIPIENT, mint: MINT, amount: createU64({ value: 500_000n }) });

  // 8. Scan (zero-arg, cursor in client.utxoDataStore).
  const scan = getBurnableStealthPoolNoteScannerFunction({ client });
  const result = await scan();

  // 9. Burn the first receiver-burnable note into the recipient's ETA.
  const r = getUmbraRelayer({ apiEndpoint: "https://relayer.api.umbraprivacy.com" });
  const burn = getReceiverBurnableStealthPoolNoteIntoETABurnerFunction(
    { client },
    {
      fetchBatchMerkleProof: client.fetchBatchMerkleProof!,
      zkProver: burnProver,
      relayer: {
        submitBurn:        r.submitClaim,
        pollBurnStatus:    r.pollClaimStatus,
        getRelayerAddress: r.getRelayerAddress,
      },
    },
  );

  // Tag scanned notes with their burn kind before passing to the burner.
  const candidates = result.etaToStealthPoolReceiverBurnable.map(
    (n) => ({ ...n, kind: "receiver-burnable" as const }),
  );
  if (candidates.length > 0) {
    const out = await burn([candidates[0]]);
    for (const [, b] of out.batches) {
      console.log("Burn status:", b.status, "sig:", b.callbackSignature ?? b.txSignature);
    }
  }
}

main().catch(console.error);

Next Steps

Wallet Adapters

Connect Phantom, Solflare, or any Solana wallet.

Stealth Pool

Anonymous transfers via Stealth Pool Notes.

Query State

Read encrypted balances and account state on-chain.

Error Handling

Recover from dropped relayer callbacks and partial burns.