Skip to main content

Overview

Umbra supports two distinct models for transferring tokens privately. They differ in what they hide, what infrastructure they require, and how strong their privacy guarantees are.

Unlinkable transfers (via the Stealth Pool)

The strongest privacy model. The on-chain link between the sender’s note write and the recipient’s burn is broken entirely — an observer cannot connect the two events by examining the chain. Tokens enter the pool as a Stealth Pool Note commitment inserted into an on-chain Indexed Merkle Tree. The note is later burned with a Groth16 ZK proof that proves ownership of the note without revealing which commitment is being spent. The note write and the burn are indistinguishable from every other note write and burn in the same tree. Note writes have four variants — (source × unlocker):
  • From an ATA — tokens enter the pool directly from an AssociatedTokenAccount. Single tx, no MPC.
  • From an ETA — tokens are drawn from an EncryptedTokenAccount before entering the pool. Two-tx pipeline (proof account → note write), MPC under the hood.
Each source has both a self-burnable variant (sender keeps the unlocker; useful for “stage funds and burn out from a fresh wallet” flows — recipient needs no on-chain state) and a receiver-burnable variant (unlocker encrypted against the recipient’s userCommitment — recipient must have completed all three registration sub-steps). Burns have three shipped variants:
  • Receiver-burnable → ETA — anonymous payment into the recipient’s encrypted balance.
  • Self-burnable → ETA — write a note, then burn it back into an encrypted balance from any wallet.
  • Self-burnable → ATA — write a note, then burn it out to a plaintext ATA from any wallet.
(Receiver-burnable → ATA exists on-chain but is not yet exposed in the SDK.) See the Stealth Pool section for the full API and per-variant pre-checks.
Receiver-burnable notes require the recipient to have completed full registration (confidential: true and anonymous: true). Self-burnable notes have no recipient-side prerequisites — they’re the right choice for one-shot transfers when the recipient hasn’t onboarded yet.

Confidential-only transfers (via EncryptedTokenAccounts)

A lighter-weight privacy model that hides token amounts and balances on-chain, but does not break the link between sender and recipient. The transfer is confidential — amounts are hidden under MPC encryption — but the participating addresses remain observable. This covers scenarios where amount privacy is the goal and linkability is an acceptable trade-off: for example, moving tokens between accounts you control without exposing the amounts, or settling a payment where both parties know each other but want the value hidden. The on-chain program supports several confidential-transfer variants. For direct ETA-to-ETA transfers, use getTransferorFunction documented below. For moving funds between ATAs and your own ETAs, use deposit / withdraw / convert.

ETA-to-ETA Direct Transfer

getTransferorFunction from @umbra-privacy/sdk/transfer performs a direct confidential transfer between two EncryptedTokenAccounts. A single MPC instruction moves the amount from the sender’s encrypted balance to the receiver’s, creating or initialising the receiver’s token account if needed. When to use this vs the Stealth Pool: Transfer settles immediately (one MPC instruction) but the on-chain event is linkable — an observer can see that two Umbra users interacted. The Stealth Pool breaks that link entirely but requires a note write followed by a separate burn, which takes longer and involves the relayer. Use Transfer when speed matters and the relationship between sender and receiver is already known; use the Stealth Pool when on-chain anonymity is the goal.

Import

import { getTransferorFunction } from "@umbra-privacy/sdk/transfer";
import { getUserAccountQuerierFunction } from "@umbra-privacy/sdk/query";

Factory pattern

const transfer = getTransferorFunction({ client }, deps?);
// deps?: { accountInfoProvider?, executorConfig? }

Recipient pre-check

The recipient must have an EncryptedTokenAccount (or the transfer will create one, provided they have an x25519PublicKey registered). Verify before calling:
const queryAccount = getUserAccountQuerierFunction({ client });
const receiverAccount = await queryAccount(receiverAddress);

if (receiverAccount.state !== "exists" || !receiverAccount.data?.isUserAccountX25519KeyRegistered) {
  throw new Error("Recipient has not registered an X25519 key on Umbra");
}

Calling the function

const result = await transfer({
  receiverAddress,
  mint,
  transferAmount,     // U64 — amount in mint's smallest unit
  optionalData?,      // OptionalData32 — pre-hashed, never plaintext
  microLamportsPerAcu?,
  accountInfoCommitment?,
});

TransferVariant — the 8 on-chain variants

The SDK auto-selects the correct variant by fetching all four accounts in one RPC round-trip:
type TransferVariant =
  | "network_to_existing_network"
  | "network_to_existing_shared"
  | "network_to_new_network"
  | "network_to_new_shared"
  | "shared_to_existing_network"
  | "shared_to_existing_shared"
  | "shared_to_new_network"
  | "shared_to_new_shared";
The two dimensions are:
  • Sender modenetwork (MXE-only balance) or shared (user-decryptable shared balance)
  • Receiver stateexisting_network, existing_shared, new_network, new_shared
Two helper guards are exported:
import { isSharedSenderVariant, isNewReceiverVariant } from "@umbra-privacy/sdk/transfer";

if (isSharedSenderVariant(result.preparation.variant)) {
  // SDK submitted the transaction; result.kind === "submitted"
}
if (isNewReceiverVariant(result.preparation.variant)) {
  // SDK created the receiver's token account as part of the transfer
}

TransferResult

The result is a discriminated union on kind:
type TransferResult =
  | { kind: "prepared";  preparation: TransferPreparation }
  | { kind: "submitted"; preparation: TransferPreparation; signature: TransactionSignature };
  • "submitted" — returned for shared_to_* variants; the SDK ran the full queue-computation pipeline.
  • "prepared" — returned for network_to_* variants; the SDK built and returned the preparation bundle and the caller decides what to do with it (typically for advanced flows or batching).

Privacy note

All 8 variants conceal the transferred amount. However, the on-chain instruction reveals that the two wallet addresses are both Umbra users and that a transfer occurred between them at a given slot. If you need to hide the relationship entirely, use a Stealth Pool note instead.