Skip to main content

Overview

Writing a Stealth Pool Note inserts a Poseidon commitment into the on-chain Indexed Merkle Tree and locks the corresponding tokens in the pool. The SDK also publishes an X25519 + AES-GCM ciphertext on-chain so the unlocker can later discover the note with their viewing keys. Choose the factory function that matches your source (ATA or ETA) and unlocker (self-burnable or receiver-burnable). All four factories live under @umbra-privacy/sdk/deposit.

Registration prerequisites differ by variant

This is the most common integration footgun.
  • Self-burnable creates encrypt the unlocker against the sender’s master-seed-derived key. Sender needs isUserAccountX25519KeyRegistered. Recipient needs nothing.
  • Receiver-burnable creates encrypt against the recipient’s userCommitment. The recipient must have completed all three registration sub-step flags (isInitialised, isUserAccountX25519KeyRegistered, isUserCommitmentRegistered) on-chain.
Always pre-check the recipient before a receiver-burnable create:
import { getUserAccountQuerierFunction } from "@umbra-privacy/sdk/query";

const queryAccount = getUserAccountQuerierFunction({ client });
const r = await queryAccount(recipient);
const ready = r.state === "exists"
  && r.data?.isInitialised
  && r.data?.isUserAccountX25519KeyRegistered
  && r.data?.isUserCommitmentRegistered;

if (!ready) {
  // Either abort with a clear "ask the recipient to register" error,
  // or fall back to a self-burnable create — see "Anonymous payment fallback" below.
}

Factory Functions

Self-burnable from ATA

Single-tx, no MPC. Funds locked directly from your ATA. You unlock.
import { getATAIntoSelfBurnableStealthPoolNoteCreatorFunction } from "@umbra-privacy/sdk/deposit";
import { getATAIntoStealthPoolNoteCreatorProver } from "@umbra-privacy/sdk/zk-prover";

const zkProver = getATAIntoStealthPoolNoteCreatorProver();

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

const signatures = await createNote({
  destinationAddress: client.signer.address, // you unlock
  mint,
  amount,
});

Receiver-burnable from ATA

Single-tx, no MPC. Funds locked from your ATA. Recipient must be fully registered.
import { getATAIntoReceiverBurnableStealthPoolNoteCreatorFunction } from "@umbra-privacy/sdk/deposit";
import { getATAIntoStealthPoolNoteCreatorProver } from "@umbra-privacy/sdk/zk-prover";

const zkProver = getATAIntoStealthPoolNoteCreatorProver();

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

const RECIPIENT = "GsbwXfJraMomNxBcpR3DBFyKCCmN9SKGzKFJBNKxRFkT";

const signatures = await createNote({
  destinationAddress: RECIPIENT,
  mint,
  amount,
});

Self-burnable from ETA

Two-tx pipeline (createProofAccountcreateUtxo), MPC. Funds drawn from your ETA. You unlock.
import { getETAIntoSelfBurnableStealthPoolNoteCreatorFunction } from "@umbra-privacy/sdk/deposit";
import { getETAIntoStealthPoolNoteCreatorProver } from "@umbra-privacy/sdk/zk-prover";

const zkProver = getETAIntoStealthPoolNoteCreatorProver();

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

const signatures = await createNote({
  destinationAddress: client.signer.address,
  mint,
  amount,
});

Receiver-burnable from ETA

Two-tx pipeline, MPC. Funds drawn from your ETA. Recipient must be fully registered.
import { getETAIntoReceiverBurnableStealthPoolNoteCreatorFunction } from "@umbra-privacy/sdk/deposit";
import { getETAIntoStealthPoolNoteCreatorProver } from "@umbra-privacy/sdk/zk-prover";

const zkProver = getETAIntoStealthPoolNoteCreatorProver();

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

const signatures = await createNote({
  destinationAddress: RECIPIENT,
  mint,
  amount,
});

Parameters

All four creator factories accept a CreateUtxoArgs object as the first argument:
args.destinationAddress
Address
required
The wallet address that can unlock this note. For self-burnable, use client.signer.address. For receiver-burnable, use the recipient’s address.
args.mint
Address
required
SPL or Token-2022 mint address. Must be a supported mint.
args.amount
bigint
required
Amount in native token units. Protocol fees and Token-2022 transfer fees are subtracted from this amount before the commitment is written.
options.generationIndex
U256
Optional deterministic generation index. Defaults to a random U256 (CSPRNG). Pass the same generationIndex on retry to allow the V18 pipeline’s closeProofAccount step to reclaim a proof-account orphan from a prior failed attempt.
options.optionalData
OptionalData32
32 bytes of opaque metadata stored alongside the note. Must be pre-hashed or pre-encrypted — never store plaintext identifiers.
options.accountInfoCommitment
Commitment
default:"\"confirmed\""
Commitment level for RPC account reads during note construction.
options.epochInfoCommitment
Commitment
default:"\"confirmed\""
(ATA-source only) Commitment level for epoch-info fetches (Token-2022 transfer-fee schedule).
options.hooks
ETAIntoStealthPoolNoteCreatorHooks | ATAIntoStealthPoolNoteCreatorHooks
Per-phase + per-step lifecycle hooks. Per-step slots use { onPreSend, onPostSend, onSkipped }. Step slot names: closeProofAccount (fires only when a stale proof account from a prior failed attempt is reclaimed), populateProofAccount, createStealthPoolNote (ATA-source pipeline only; ETA-source uses queueComputation instead).
deps.zkProver
ZkProver
required
Circuit-specific Groth16 prover. Required for every variant — there is no built-in default. Provers ship at @umbra-privacy/sdk/zk-prover.
V4 fields priorityFees, purpose, awaitCallback, skipPreflight, maxRetries do not exist on V5 note creators. The V4 callbacks: { createProofAccount, createUtxo, closeProofAccount } shape has been replaced by options.hooks (ATA-source) or deps.hooks (ETA-source) with onPreSend/onPostSend/onSkipped per-step events.

Return Value

The return type depends on the source:
  • From ATA (ZK-only): Promise<TransactionSignature[]> of length 1 — [noteWriteSig].
  • From ETA (MPC pipeline): Promise<TransactionSignature[]> of length 2 — [proofAccountSig, noteWriteSig].

The ZK prover dependency

Note creation requires a ZK prover function (zkProver in deps). This function generates a Groth16 proof that the commitment was constructed correctly. The prover is CPU-intensive — generating a proof takes 2–8 seconds in the browser and 1–3 seconds in Node.js. For browser applications, run the prover in a Web Worker to avoid blocking the main thread. See ZK Provers for the canonical comlink pattern.
The zkProver dependency is required and cannot be omitted. There is no built-in default. Use @umbra-privacy/sdk/zk-prover for the CDN-backed implementation.

Anonymous payment fallback (receiver-burnable → self-burnable)

For one-shot payments to a recipient who may not be registered, prefer the self-burnable ATA variant + share a recovery secret out of band (or use the relayer’s escrow flow). This avoids the “recipient must register first” UX trap:
import { getUserAccountQuerierFunction } from "@umbra-privacy/sdk/query";
import {
  getATAIntoReceiverBurnableStealthPoolNoteCreatorFunction,
  getATAIntoSelfBurnableStealthPoolNoteCreatorFunction,
} from "@umbra-privacy/sdk/deposit";
import {
  getATAIntoStealthPoolNoteCreatorProver,
} from "@umbra-privacy/sdk/zk-prover";

const queryAccount = getUserAccountQuerierFunction({ client });
const r = await queryAccount(RECIPIENT);
const recipientReady = r.state === "exists"
  && r.data?.isInitialised
  && r.data?.isUserAccountX25519KeyRegistered
  && r.data?.isUserCommitmentRegistered;

if (recipientReady) {
  const create = getATAIntoReceiverBurnableStealthPoolNoteCreatorFunction(
    { client },
    { zkProver: getATAIntoStealthPoolNoteCreatorProver() },
  );
  await create({ destinationAddress: RECIPIENT, mint, amount });
} else {
  // Fallback: sender stays the unlocker, hand over the secret out of band.
  const create = getATAIntoSelfBurnableStealthPoolNoteCreatorFunction(
    { client },
    { zkProver: getATAIntoStealthPoolNoteCreatorProver() },
  );
  await create({ destinationAddress: client.signer.address, mint, amount });
}

Error Handling

Create-side failures have two distinguishing modes — ZK proof generation and stale on-chain state. Use isCreateUtxoError from @umbra-privacy/sdk/errors:
import { isCreateUtxoError } from "@umbra-privacy/sdk/errors";

try {
  const signatures = await createNote({ destinationAddress: recipient, mint, amount });
} catch (err) {
  if (isCreateUtxoError(err)) {
    switch (err.stage) {
      case "zk-proof-generation":
        console.error("Proof generation failed:", err.message);
        break;
      case "transaction-sign":
        console.log("Cancelled.");
        break;
      case "account-fetch":
        console.error("RPC error:", err.message);
        break;
      case "transaction-send":
        // May have landed. Re-run the scanner to check whether the commitment was inserted.
        console.warn("Confirmation timeout. Check on-chain before retrying.");
        break;
      default:
        console.error("Create failed at:", err.stage, err);
    }
  } else {
    throw err;
  }
}
On transaction-send, do not immediately retry. Run the scanner first — the commitment may already be in the tree. If retrying an ETA-source create, you must pass the same generationIndex via options.generationIndex so the SDK’s closeProofAccount step can reclaim the orphan from your prior attempt (see Recovery).
See Error Handling for a full reference.

Concurrency rule

Never run note creates concurrently from the same client. The SDK auto-derives generationIndex from on-chain account state during each call; two parallel Promise.all-style creates read the same generationIndex before either has incremented it, derive identical ephemeral keys, and collide silently (fund loss / scan failure). Serialise creates per (signer, network).

Protocol Fees

Fees are deducted from amount before the commitment is written. The net amount committed (= what the unlocker receives at burn time) is amount − fees. See Pricing for current rates.