Overview
Because all Umbra cryptographic keys are derived deterministically from a wallet signature, recovery is straightforward: recreate the client from the same wallet on the same network, and all keys are automatically restored.
No seed phrases, no backup files, no recovery codes. The wallet is the single source of truth.
What “recovery” means
When a user loses access to a session (page refresh, browser restart, new device), the following state is lost:
- Master seed — cached in memory. Must be re-derived.
- Derived keys — computed on demand from the master seed. Automatically re-derived.
utxoDataStore cursor — cached locally (IndexedDB or whatever backend you wired). Lost if you didn’t persist.
The following state is never lost (stored on-chain):
- Registered user account (
EncryptedUserAccount PDA).
- Registered X25519 public key.
- Registered user commitment.
- ETA balances.
- Stealth Pool Note commitments in the Indexed Merkle Tree.
- Active compliance grants.
Re-deriving keys
Re-creating the client from the same wallet automatically re-derives the same master seed and all keys:
import { getUmbraClient, createSignerFromWalletAccount } from "@umbra-privacy/sdk";
import {
createBrowserStorageBackend,
createShardedUtxoDataStore,
createShardedNullifierStore,
} from "@umbra-privacy/sdk/store-adapters";
// On a new session, device, or after a refresh.
const signer = createSignerFromWalletAccount(wallet, account);
const storageBackend = createBrowserStorageBackend({ dbName: "umbra" });
const utxoDataStore = createShardedUtxoDataStore({ storageBackend });
const nullifierStore = createShardedNullifierStore({ storageBackend });
const client = await getUmbraClient({
signer,
network: "mainnet",
rpcUrl,
rpcSubscriptionsUrl,
indexerApiEndpoint: "https://utxo-indexer.api.umbraprivacy.com",
relayerApiEndpoint: "https://relayer.api.umbraprivacy.com",
utxoDataStore,
nullifierStore,
deferMasterSeedSignature: false, // prompt the wallet immediately.
});
All keys are now restored. ETA balances remain accessible. Stealth Pool Notes can be re-discovered via the scanner.
The user is prompted once for the master seed derivation signature. After that, all keys are available for the session lifetime.
Re-scanning Stealth Pool Notes
The indexer stores all note ciphertexts permanently. After recovery, run the zero-arg scanner — it walks every active tree and persists progress in client.utxoDataStore:
import { getBurnableStealthPoolNoteScannerFunction } from "@umbra-privacy/sdk/burn";
const scan = getBurnableStealthPoolNoteScannerFunction({ client });
const result = await scan();
console.log("Recovered self-burnable from ETA: ", result.etaToStealthPoolSelfBurnable.length);
console.log("Recovered receiver-burnable from ETA: ", result.etaToStealthPoolReceiverBurnable.length);
console.log("Recovered self-burnable from ATA: ", result.ataToStealthPoolSelfBurnable.length);
console.log("Recovered receiver-burnable from ATA: ", result.ataToStealthPoolReceiverBurnable.length);
If utxoDataStore was not persisted (e.g. you didn’t wire createBrowserStorageBackend), the first post-recovery scan walks every active tree from genesis. On mainnet this can be slow but is one-shot — subsequent scans are incremental.
Dropped MPC callback recovery
When a deposit, withdrawal, or conversion handler succeeds but the Arcium MPC callback never lands (network partition, compute budget, Arcium outage), tokens stay staged in the pool ATA. Two recoverer factories reclaim them — both synchronous, no MPC, no ZK proof:
import {
getStagedSplRecovererFunction,
getStagedSolRecovererFunction,
} from "@umbra-privacy/sdk/account";
// SPL token recovery.
const recoverSpl = getStagedSplRecovererFunction({ client });
await recoverSpl({ mint });
// wSOL recovery.
const recoverSol = getStagedSolRecovererFunction({ client });
await recoverSol({ mint: WSOL_MINT });
Failed note-create recovery
A failed Stealth Pool Note create can leave two kinds of orphans:
(a) Proof-account orphan — every variant. The pipeline’s first step is the closeProofAccount hook (built-in), which auto-detects and closes a pre-existing proof account from a previous attempt. Just re-run the create.
(b) Input-buffer orphan — MPC variants only (ETA-source creates). The input buffer at (generationIndex, depositor) holds ~4.85M lamports until closed. The SDK only detects it if you retry with the same generationIndex. You must persist generationIndex before signing and pass it back via options.generationIndex on retry. See pitfalls — failed UTXO creates.
Idempotent burn retry
The relayer’s MPC callback can drop. The burner factory polls the relayer to terminal state and treats NullifierAlreadyBurnt as idempotent success. If you build a custom pipeline that bypasses the orchestrator, the contract is:
- Poll
GET /v1/claims/{request_id} to terminal state.
- Verify on-chain that the nullifier is not already burnt.
- Re-submit only if still unspent.
- On
HTTP 409 DUPLICATE_OFFSET, wait, re-check, retry only if still unspent.
Persisting the master seed to avoid re-prompting
If you want to avoid the wallet signing prompt on every page load, persist the master seed using masterSeedStorage. The seed is equivalent in sensitivity to a private key.
const client = await getUmbraClient(
{ signer, network: "mainnet", rpcUrl, rpcSubscriptionsUrl },
{
masterSeedStorage: {
load: async () => {
const stored = sessionStorage.getItem("umbra:seed");
if (!stored) return { exists: false };
return { exists: true, seed: new Uint8Array(JSON.parse(stored)) };
},
store: async (seed) => {
sessionStorage.setItem("umbra:seed", JSON.stringify(Array.from(seed)));
},
},
},
);
For React Native / mobile applications, use the platform’s secure storage:
import * as SecureStore from "expo-secure-store";
const client = await getUmbraClient(
{ signer, network: "mainnet", rpcUrl, rpcSubscriptionsUrl },
{
masterSeedStorage: {
load: async () => {
const stored = await SecureStore.getItemAsync("umbra:seed");
if (!stored) return { exists: false };
return { exists: true, seed: new Uint8Array(JSON.parse(stored)) };
},
store: async (seed) => {
await SecureStore.setItemAsync("umbra:seed", JSON.stringify(Array.from(seed)));
},
},
},
);
Production storage requires encrypted-at-rest with a key the user controls (WebAuthn-derived, password-derived via Argon2id, or wallet-signed challenge) — and scoped per-wallet (key includes the wallet pubkey). A wallet swap MUST NOT load the previous wallet’s seed. Plaintext localStorage / IndexedDB is unacceptable. The SDK scaffold defaults to re-derive every session.
Key Rotation
If you suspect a key may be compromised, rotate it. Two mechanisms exist:
(a) Offset rotation — increment the relevant offset at client construction time. The resulting derived key is completely different. Re-register on-chain to publish the new key. Existing on-chain state encrypted under the old key becomes inaccessible.
const client = await getUmbraClient({
signer,
network: "mainnet",
rpcUrl,
rpcSubscriptionsUrl,
offsets: {
x25519UserAccountPrivateKey: 1n, // incremented from default 0n
},
});
const register = getUserRegistrationFunction({ client });
await register({ confidential: true, anonymous: false });
(b) Active on-chain rotation — the factories in @umbra-privacy/sdk/account issue a new key and re-encrypt existing on-chain state under it. Use these when you want to preserve current balances:
getMintEncryptionKeyRotatorFunction({ client }) — MPC; rotates a per-mint ETA key and re-encrypts the balance.
getMasterViewingKeyRotatorFunction({ client }) — MPC + ZK; rotates the MVK X25519 + user commitment.
getUserEncryptionKeyRotatorFunction({ client }) — synchronous, no MPC; rotates the user-account X25519 token-encryption key.
Maintenance (subpath @umbra-privacy/sdk/account):
getUserEntropySeedRotatorFunction({ client }) — rotate the user-account random generation seed.
getTokenEntropySeedRotatorFunction({ client }) — rotate a per-mint random generation seed.
Offset rotation does NOT migrate on-chain state. Funds in an old-key ETA must be withdrawn (or rotated via the active rotator) before the old offset is retired. Compliance grants tied to the old MVK X25519 must be revoked and re-issued under the new key.
Multi-device access
Because all keys are derived from the wallet, the same wallet on any device produces the same keys. There is no special sync protocol — just connect the same wallet on the new device and create a client.
The only exceptions are device-local: the masterSeed cache and the utxoDataStore / nullifierStore shards. On a new device the user is prompted once to re-derive the seed, then the first scan rebuilds the local state from the indexer.