Skip to main content

Overview

Burning a Stealth Pool Note presents a ZK proof on-chain that you know the secret inputs behind a Merkle tree commitment, burns the nullifier to prevent double-spending, and releases the tokens. You choose where the tokens go:
  • Into an EncryptedTokenAccount — tokens remain private after burning. Default for receiver-burnable.
  • Into an AssociatedTokenAccount — tokens become visible in the destination ATA. Self-burnable only in V18.

V18 burner factories

All three live under @umbra-privacy/sdk/burn.

Receiver-burnable → ETA

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,
    },
  },
);

const result = await burn(notes); // array of DecryptedStealthPoolNoteData

Self-burnable → ETA

import {
  getSelfBurnableStealthPoolNoteIntoETABurnerFunction,
} from "@umbra-privacy/sdk/burn";
import { getUmbraRelayer } from "@umbra-privacy/sdk";
import { getClaimSelfClaimableUtxoIntoEncryptedBalanceProver } from "@umbra-privacy/sdk/zk-prover";

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

const burn = getSelfBurnableStealthPoolNoteIntoETABurnerFunction(
  { client },
  {
    fetchBatchMerkleProof: client.fetchBatchMerkleProof!,
    zkProver,
    relayer: {
      submitBurn:        r.submitClaim,
      pollBurnStatus:    r.pollClaimStatus,
      getRelayerAddress: r.getRelayerAddress,
    },
  },
);

const result = await burn(notes);

Self-burnable → ATA

import {
  getSelfBurnableStealthPoolNoteIntoATABurnerFunction,
} from "@umbra-privacy/sdk/burn";
import { getUmbraRelayer } from "@umbra-privacy/sdk";
import { getClaimSelfClaimableUtxoIntoPublicBalanceProver } from "@umbra-privacy/sdk/zk-prover";

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

const burn = getSelfBurnableStealthPoolNoteIntoATABurnerFunction(
  { client },
  {
    fetchBatchMerkleProof: client.fetchBatchMerkleProof!,
    zkProver,
    relayer: {
      submitBurn:        r.submitClaim,
      pollBurnStatus:    r.pollClaimStatus,
      getRelayerAddress: r.getRelayerAddress,
    },
  },
);

const result = await burn(notes);
Receiver-burnable → ATA is supported on-chain (claim_to_receiver in the anchor program) but is not yet exposed in the SDK. Use receiver-burnable → ETA, then a regular withdrawal, until the SDK ships the direct path.

Function signature

All three burners share the same call signature:
type BurnerFn = (
  stealthPoolNotes: readonly (DecryptedStealthPoolNoteData & { kind: "self-burnable" | "receiver-burnable" })[],
  optionalData?: OptionalData32,
  microLamportsPerAcu?: MicroLamportsPerAcu,
) => Promise<BurnStealthPoolNoteIntoETAResult | BurnStealthPoolNoteIntoATAResult>;

Parameters

stealthPoolNotes
readonly (DecryptedStealthPoolNoteData & { kind })[]
required
Notes returned by the scanner — pick them out of result.{etaToStealthPoolSelfBurnable, etaToStealthPoolReceiverBurnable, ataToStealthPoolSelfBurnable, ataToStealthPoolReceiverBurnable}. The burner fetches per-batch Merkle proofs internally; do not pre-attach proofs.
optionalData
OptionalData32
32 bytes of opaque metadata stored with the burn. Defaults to all zeros. Must be a pre-hashed or pre-encrypted 32-byte value.
microLamportsPerAcu
MicroLamportsPerAcu
Optional priority fee in micro-lamports per Arcium computation unit, applied to the relayer-submitted transactions.

Deps

fetchBatchMerkleProof
BatchMerkleProofFetcherFunction
required
Per-batch Merkle proof fetcher. Use client.fetchBatchMerkleProof (auto-built when indexerApiEndpoint is set on the client).
zkProver
ZkProver
required
Circuit-specific Groth16 prover (one per burn variant). Provers ship at @umbra-privacy/sdk/zk-prover.
relayer
{ submitBurn; pollBurnStatus; getRelayerAddress }
required
Relayer adapter. The three property names are V18 — they are TypeScript aliases (BurnSubmitterFunction = ClaimSubmitterFunction, etc.), so a relayer client built via getUmbraRelayer({ apiEndpoint }) plugs in directly under the renamed property names.

Return Value

Receiver-into-ETA and self-into-ETA both return BurnStealthPoolNoteIntoETAResult. Self-into-ATA returns BurnStealthPoolNoteIntoATAResult. Both shapes:
interface BurnStealthPoolNoteIntoETAResult {
  readonly signatures: readonly TransactionSignature[]; // from OperationOutcome — every tx submitted
  readonly batches: Map<U32, BurnBatchResult>;          // keyed by batch index
}

interface BurnBatchResult {
  readonly requestId: string;                                       // relayer-assigned tracking ID
  readonly status: BurnStatus;
  readonly txSignature?: string;                                    // on-chain burn tx (when landed)
  readonly callbackSignature?: string;                              // MPC callback tx (when finalised)
  readonly resolvedVariant?: string;                                // relayer-chosen variant, e.g. "claim_into_existing_shared_balance_v18"
  readonly failureReason?: string | null;
  readonly stealthPoolNoteIds?: readonly string[];                  // "treeIndex:leafIndex" pairs
}

type BurnStatus =
  | "received" | "validating" | "offsets_reserved" | "building_tx" | "tx_built"
  | "submitting" | "submitted" | "awaiting_callback" | "callback_received"
  | "finalizing" | "completed" | "failed" | "timed_out" | "refunded";
completed / callback_received indicate success. The signature you want to surface to the user is callbackSignature ?? txSignature. failureReason containing "NullifierAlreadyBurnt" means the note was already burnt — the SDK treats this as idempotent success.
Umbra Confidential SPL token — lottery points: When burning into an ETA, the Arcium MPC generates a rescue_encrypted_lottery_ticket_delta and adds it to your ETA’s running point totals. This field is present in the raw relayer batch response but is not yet surfaced in BurnBatchResult. See Umbra Confidential SPL Token for the current SDK status.

Batching behaviour

  • Receiver-burnable → ETA batches natively: groups notes by destinationAddress, chunks to ≤5 per proof. Pass the whole array; do not reimplement chunking.
  • Self-burnable → ETA / ATA has MAX_NOTES_PER_PROOF = 1. The SDK loops internally — caller still passes an array; the result has one batch per note.

Full example: scan + burn

import { getBurnableStealthPoolNoteScannerFunction } from "@umbra-privacy/sdk/burn";
// ...the burner setup from above

const scan   = getBurnableStealthPoolNoteScannerFunction({ client });
const result = await scan();

if (result.ataToStealthPoolReceiverBurnable.length > 0) {
  const out = await burnReceiverIntoEta(result.ataToStealthPoolReceiverBurnable);
  for (const [batchIndex, batch] of out.batches) {
    console.log(
      "Batch", batchIndex,
      "status:", batch.status,
      "sig:",    batch.callbackSignature ?? batch.txSignature,
      "id:",     batch.requestId,
    );
  }
}

ZK Proof Generation

Burns generate a Groth16 ZK proof that proves:
  • You know the secret inputs to one of the commitments in the Merkle tree.
  • The corresponding nullifier has not been burnt before.
The proof is verified on-chain by the Umbra program. Invalid proofs are rejected; valid proofs trigger the callback instruction that releases the tokens. Proof generation is CPU-intensive. Plan for 2–8 seconds in the browser per batch. Run the prover in a Web Worker to avoid blocking the main thread (see ZK Provers).

Stale Merkle Proofs

Merkle proofs can become stale if the tree root changes between when the proof is fetched and when the burn is submitted. In V18 the burner fetches the proof per batch, immediately before submission, so this is much less likely than under V13’s scan-time proof bundling. If a burn fails with a root mismatch, simply re-run it — the burner refetches the proof on each attempt.
Do not call enrichWithMerkleProof yourself in normal use. The burner handles it. The helper is exported from @umbra-privacy/sdk/burn only for callers building a fully custom burn pipeline that bypasses the orchestrator factories.

Dropped relayer callback

The relayer’s MPC callback can drop (network partition, compute-budget exhaustion, Arcium downtime). If you re-submit the same request_id while the nullifier is still reserved upstream, the relayer returns HTTP 409 with code DUPLICATE_OFFSET — wait, re-check on-chain that the nullifier has not landed, and retry. The burner factory handles this automatically: polling, detecting NullifierAlreadyBurnt as idempotent success, and surfacing a clean status. If you are building a custom burn pipeline, see Errors — Idempotent burn retry.

Error Handling

import { isClaimUtxoError } from "@umbra-privacy/sdk/errors";

try {
  const result = await burn(notes);
} catch (err) {
  if (isClaimUtxoError(err)) {
    switch (err.stage) {
      case "zk-proof-generation":
        console.error("Proof generation failed:", err.message);
        break;
      case "transaction-validate":
        // Pre-flight failed — Merkle proof may be stale. Re-run the burner; it refetches.
        console.warn("Pre-flight failed:", err.message);
        break;
      case "transaction-send":
        // Submitted but confirmation timed out. Verify on-chain before retrying —
        // the nullifier may already be burnt.
        console.warn("Confirmation timeout. Check on-chain before retrying.");
        break;
      default:
        console.error("Burn failed at stage:", err.stage, err);
    }
  } else {
    throw err;
  }
}
On transaction-send, do not immediately retry. The transaction may have landed; the burner factory checks on-chain before re-submission, but a fresh manual retry without that check can attempt to spend a nullifier that has already been burnt — wasting fees on a rejected tx.

Nullifier Reuse

Once a note is burnt, its nullifier is recorded in an on-chain treap. Any attempt to burn the same note again will be rejected by the program with NullifierAlreadyBurnt. You do not need to track this client-side — the on-chain check is authoritative, and the SDK’s burner treats this status as idempotent success. See Error Handling for the full error reference.