Skip to main content

Overview

Before a user can deposit into an EncryptedTokenAccount or interact with the Stealth Pool, they need an on-chain Umbra user account. Registration creates this account and optionally registers the cryptographic keys needed for ETAs and anonymous transfers. Registration is idempotent — it checks on-chain state before each step and skips any sub-steps already completed (handling key rotation when keys have changed). Each call may submit on-chain transactions with SOL costs, so in practice you should check whether the account is already registered before calling it.

Usage

import { getUserRegistrationFunction } from "@umbra-privacy/sdk/registration";

const register = getUserRegistrationFunction({ client });

const signatures = await register({
  confidential: true, // register the X25519 key (Shared ETAs + receiver-burnable notes)
  anonymous: true,    // register the user commitment (Stealth Pool transfers as the recipient)
});

console.log(`Completed ${signatures.length} registration transaction(s)`);
// 0 if already fully registered.
// 1–2 depending on which sub-steps were needed (registration splits into two independent on-chain instructions).

Options

confidential
boolean
default:"true"
Register the X25519 token-encryption pubkey for Shared-mode ETAs. Without this, deposits stay in MXE-only mode and you cannot query your balance locally.
anonymous
boolean
default:"true"
Register the on-chain user commitment via MPC + ZK. Required to receive receiver-burnable Stealth Pool Notes. Can be used independently of confidential.
hooks
RegistrationHooks
Per-step lifecycle hooks plus top-level phase hooks. Skippable step slots take { onPreSend, onPostSend, onSkipped }.
await register({
  confidential: true,
  anonymous: true,
  hooks: {
    // Top-level phase hooks
    onValidationStart:    async () => console.log("Validating..."),
    onAccountFetchComplete: async ({ userAccountExists, hasX25519Key, isAnonymous }) =>
      console.log("Account state:", { userAccountExists, hasX25519Key, isAnonymous }),
    onKeyDerivationComplete: async ({ elapsedMs }) => console.log("Keys ready in", elapsedMs, "ms"),

    // Per-step hooks (skippable; onSkipped fires when state is already correct)
    initUserAccount: {
      onSkipped: async ({ reason }) => console.log("Account already exists:", reason),
      onPreSend: async ({ signedTransaction }) => console.log("Creating account..."),
      onPostSend: async ({ signature }) => console.log("Account created:", signature),
    },
    registerX25519PublicKey: {
      onSkipped: async ({ reason }) => console.log("X25519 key already registered:", reason),
      onPreSend: async () => console.log("Registering X25519 key..."),
      onPostSend: async ({ signature }) => console.log("X25519 registered:", signature),
    },
    registerAnonymousUsage: {
      onSkipped: async ({ reason }) => console.log("Anonymous usage already enabled:", reason),
      onPreSend: async () => console.log("Registering user commitment..."),
      onPostSend: async ({ signature }) => console.log("Commitment registered:", signature),
    },

    // Terminal
    onComplete: async (result) => console.log("Registration complete:", result),
    onError:    async ({ phase, error }) => console.error("Failed in phase", phase, ":", error),
  },
});
The hook slot names are initUserAccount, registerX25519PublicKey, registerAnonymousUsage. Each is SkippableDirectTransactionStepHooks or SkippableMpcTransactionStepHooks{ onPreSend, onPostSend, onSkipped }.

The registration flow

Registration is two independent on-chain instructions. Both init_if_needed the EncryptedUserAccount PDA, so either can run first.
1

register_user_for_confidential_usage

Synchronous. Initialises the EncryptedUserAccount PDA (if needed) and stores the X25519 token-encryption pubkey derived from your master seed. Enables Shared encryption mode — deposits get encrypted under both the Arcium MPC key and your key, so you can decrypt your own balance locally.Run when confidential: true.
2

register_user_for_anonymous_usage_v18

MPC + ZK. Initialises the user account (if needed), stages master-viewing-key material, runs Fiat-Shamir + Groth16, queues the Arcium MPC computation, and on callback stores the on-chain user commitment. Enables you to receive receiver-burnable Stealth Pool Notes.Run when anonymous: true.
Both sub-steps require the master seed to be derived (overridable via dependency injection). The wallet signing prompt fires the first time the seed is needed if it has not been derived yet in this session.

On-Chain Account

After registration, the user’s EncryptedUserAccount PDA stores:
  • X25519 public key (for Shared mode deposits addressed to this user)
  • Poseidon user commitment (for ZK proof generation)
  • Generation index (monotonic counter used for nonce derivation)
  • Random generation seed (entropy mixed into nonces)
  • Status flags for each registration step
This account is the persistent on-chain identity for the wallet address. It does not hold any tokens.

Error Handling

Registration can fail at several distinct points. Use isRegistrationError from @umbra-privacy/sdk/errors and switch on err.stage to handle each one.
import { isRegistrationError } from "@umbra-privacy/sdk/errors";

try {
  await register({ confidential: true, anonymous: true });
} catch (err) {
  if (isRegistrationError(err)) {
    switch (err.stage) {
      case "master-seed-derivation":
        // User declined to sign the master seed derivation message.
        // Without the seed, no keys can be derived and registration cannot proceed.
        showNotification("Please sign the master seed message to set up your account.");
        break;

      case "transaction-sign":
        // User rejected one of the registration transactions in their wallet.
        showNotification("Registration cancelled.");
        break;

      case "zk-proof-generation":
        // ZK proof generation failed. Only applies to the commitment step (anonymous: true).
        console.error("Proof generation failed:", err.message);
        showNotification("Failed to generate proof. Please try again.");
        break;

      case "account-fetch":
        // RPC connectivity issue while fetching on-chain account state.
        console.error("RPC error during registration:", err.message);
        break;

      case "transaction-send":
        // Transaction submitted but confirmation timed out.
        // The transaction may have landed - check on-chain before retrying.
        console.warn("Confirmation timeout. Check on-chain before retrying.");
        break;

      default:
        console.error("Registration failed at stage:", err.stage, err);
    }
  } else {
    throw err;
  }
}
Partial completion: Registration submits up to two separate transactions. If the second transaction fails or is cancelled, the first is already confirmed on-chain. Call register() again — it checks on-chain state and skips the sub-step that already completed. See Error Handling for a full reference of all error types.