Skip to main content

The IUmbraSigner Interface

The Umbra SDK requires a signer that implements IUmbraSigner:
interface IUmbraSigner {
  readonly address: Address;
  signTransaction(tx: SignableTransaction): Promise<SignedTransaction>;
  signTransactions(txs: readonly SignableTransaction[]): Promise<SignedTransaction[]>;
  signMessage(message: Uint8Array): Promise<SignedMessage>;
}
signMessage is critical - it is used to derive the master seed from a wallet signature. The other methods sign the Solana transactions that interact with the Umbra program. The SDK exports ready-made helper functions for every common signer source. You do not need to implement IUmbraSigner yourself.

Option 1: In-Memory Keypair (Testing)

Use the SDK’s built-in helpers to generate or load an in-memory keypair. Best for scripts, CI, and local development.
import {
  createInMemorySigner,
  createSignerFromPrivateKeyBytes,
  createSignerFromKeyPair,
} from "@umbra-privacy/sdk";

// Generate a new random keypair
const signer = await createInMemorySigner();
console.log("Address:", signer.address);

// Load from raw bytes (64-byte keypair or 32-byte seed - @solana/kit accepts both)
import { readFileSync } from "fs";
const keyFile = JSON.parse(readFileSync("/path/to/keypair.json", "utf8"));
const signer = await createSignerFromPrivateKeyBytes(new Uint8Array(keyFile));

// Adapt an existing @solana/kit KeyPairSigner you already hold
import { generateKeyPairSigner } from "@solana/kit";
const kps = await generateKeyPairSigner();
const signer = createSignerFromKeyPair(kps);
In-memory keypairs are ephemeral. If your process restarts, the keypair is gone. Only use this for testing or scripts where you control the key lifecycle.

Option 2: Wallet Standard Browser Wallet (Production)

Modern Solana wallets — Phantom, Backpack, Solflare, and others — implement the Wallet Standard. Use createSignerFromWalletAccount to adapt them to IUmbraSigner. You obtain the wallet and account objects by discovering registered wallets with getWallets() from @wallet-standard/app, then calling the wallet’s standard:connect feature.
pnpm add @wallet-standard/app @wallet-standard/base @wallet-standard/features
import { getWallets } from "@wallet-standard/app";
import type { Wallet, WalletAccount } from "@wallet-standard/base";
import { StandardConnect, StandardDisconnect } from "@wallet-standard/features";
import {
  createSignerFromWalletAccount,
  getUmbraClient,
} from "@umbra-privacy/sdk";

// 1. Discover wallets that support Solana signing
const { get } = getWallets();
const solanaWallets = get().filter((w) => {
  const features = Object.keys(w.features);
  return (
    features.includes("solana:signTransaction") &&
    features.includes("solana:signMessage")
  );
});

// 2. Connect to a wallet (prompts the user)
const wallet = solanaWallets[0]; // e.g. Phantom
const connectFeature = wallet.features[StandardConnect];
const { accounts } = await connectFeature.connect();
const account = accounts[0];

// 3. Create the Umbra signer
const signer = createSignerFromWalletAccount(wallet, account);

// 4. Create the Umbra client
const client = await getUmbraClient({
  signer,
  network: "mainnet",
  rpcUrl: "https://api.mainnet-beta.solana.com",
  rpcSubscriptionsUrl: "wss://api.mainnet-beta.solana.com",
  indexerApiEndpoint: "https://utxo-indexer.api.umbraprivacy.com",
  deferMasterSeedSignature: true, // prompt fires on first operation, not at connect time
});
For React applications, you can also use the useWallets() hook from @wallet-standard/react and the useConnect() / useDisconnect() hooks for a more ergonomic integration:
import { useWallets, useConnect } from "@wallet-standard/react";
import { createSignerFromWalletAccount } from "@umbra-privacy/sdk";
Re-create the client whenever the wallet connection changes. Since the client holds a reference to the signer, stale signers will cause transactions to fail.
The wallet must support both "solana:signTransaction" and "solana:signMessage" features — an error is thrown immediately if either is missing.

How signing works

  1. The @solana/kit transaction is serialized to wire-format bytes via getTransactionEncoder()
  2. The bytes are passed directly to the wallet’s solana:signTransaction feature
  3. The wallet returns signed wire bytes
  4. getTransactionDecoder() reconstructs the @solana/kit Transaction, and signatures are merged back in
This path is compatible with all Wallet Standard wallets and does not require @solana/web3.js.

Master Seed Derivation

The first time any cryptographic operation runs (typically during register()), the SDK calls signer.signMessage with a deterministic message to derive the master seed (overridable via dependency injection). This produces a one-time wallet signing prompt. The message that the user signs is a multi-paragraph legal consent and acknowledgement. It is exported as UMBRA_MESSAGE_TO_SIGN from @umbra-privacy/sdk:
import { UMBRA_MESSAGE_TO_SIGN } from "@umbra-privacy/sdk";
The signature is then hashed with KMAC256 (dkLen=64) to produce the 512-bit master seed.
The master seed is derived deterministically from this signature. If the message changed, a completely different master seed would be produced - all previously registered keys would become inaccessible. Never modify the derivation message.
After derivation, the seed is cached in memory for the lifetime of the client object. The prompt does not appear again unless the client is recreated. To control exactly when the prompt fires, use the deferMasterSeedSignature option in getUmbraClient. See Creating a Client - Master Seed Derivation.

Persisting the Master Seed

By default, the master seed lives only in memory. If you want to avoid re-deriving it on every page load, you can persist it using the masterSeedStorage dependency override:
import { getUmbraClient } from "@umbra-privacy/sdk";

const client = await getUmbraClient(
  { signer, network: "mainnet", rpcUrl, rpcSubscriptionsUrl },
  {
    masterSeedStorage: {
      load: async () => {
        const stored = sessionStorage.getItem("umbra:masterSeed");
        if (!stored) return { exists: false };
        return { exists: true, seed: new Uint8Array(JSON.parse(stored)) };
      },
      store: async (seed) => {
        sessionStorage.setItem("umbra:masterSeed", JSON.stringify(Array.from(seed)));
      },
    },
  }
);
The master seed is a 64-byte root secret equivalent in sensitivity to a private key. Only persist it in secure storage - never in localStorage in plaintext. sessionStorage is slightly better (cleared on tab close) but still not suitable for long-term storage. For production applications, consider deriving it on every session or using a secure enclave.