Skip to main content

Why Branded Types Exist

TypeScript uses structural typing — two types with the same underlying representation are treated as identical by the compiler. This means a raw bigint that represents an encrypted balance ciphertext is structurally the same as one that represents a Poseidon encryption key, or a U64 token amount. Without extra guardrails, you can pass the wrong value and TypeScript will not warn you. The SDK uses branded types (also called nominal types) to prevent this class of silent bug. Every cryptographic and numeric value in the SDK carries a phantom brand that makes it distinct from every other value with the same base type — at compile time, with zero runtime overhead.
// Without branding — TypeScript accepts both, one is silently wrong:
function encryptBalance(key: bigint, plaintext: bigint) { ... }
encryptBalance(myBalance, myKey); // Oops — args are swapped, no error

// With branding — the compiler catches the swap:
function encryptBalance(key: RescueCipherKey, plaintext: RescueCipherPlaintext) { ... }
encryptBalance(myBalance, myKey); // Error: RescueCipherPlaintext is not assignable to RescueCipherKey

The Type Hierarchy

The SDK’s branded types form a hierarchy. A more specific type is always assignable to its parent, but a parent is never assignable to a more specific child without an explicit helper call. Integer types (base: bigint):
  • U64 — 64-bit unsigned integer (0 to 2^64 - 1). Used for token amounts and instruction parameters.
  • U128 — 128-bit unsigned integer. Used for instruction seeds and generation indices.
  • U256 — 256-bit unsigned integer. Used for Stealth Pool Note commitment preimages and random generation seeds.
Cryptographic field elements (base: bigint, extend U256):
  • Bn254FieldElement — BN254 scalar field element. Strict upper bound: the BN254 field prime.
  • Curve25519FieldElement — Curve25519 field element. Strict upper bound: 2^255 - 19.
Poseidon types (extend Bn254FieldElement):
  • PoseidonKey — Encryption key for the Poseidon cipher scheme. Derived from the user’s master seed.
Rescue Cipher types (extend Curve25519FieldElement, except the nonce):
  • RescueCipherPlaintext — An unencrypted balance value.
  • RescueCipherCiphertext — An encrypted balance value stored on-chain.
  • RescueCipherKey — A session encryption key derived from the Poseidon key and account nonce.
  • RescueCipherCounter — The Rescue CTR-mode counter, advanced once per block.
  • RescueCipherEncryptionNonce — The monotonically increasing 128-bit counter (a sub-brand of U128, not a field element) that initialises the cipher’s CTR state so each encryption produces a distinct ciphertext.
Protocol types (base: Uint8Array or bigint):
  • OptionalData32 — A 32-byte opaque payload stored alongside deposits and burns. Must be encrypted or hashed — never store plaintext identifiers.
  • MicroLamportsPerAcu — Priority fee in micro-lamports per Arcium Computation Unit.
Byte array types (base: Uint8Array):
  • X25519PublicKey, X25519PrivateKey, SharedSecret — 32-byte X25519 values.
  • Sized little-endian/big-endian variants (U64LeBytes, U256BeBytes, etc.) for serialisation.

The create* Helper Functions

Raw bigint or Uint8Array values you read from on-chain accounts, compute locally, or receive from external sources are unbranded. Before passing them to any SDK function that expects a branded type, run them through the corresponding create* helper. Each helper:
  1. Validates the value at runtime (range check, type check, length check).
  2. Brands the value — returns it typed as the specific branded type.
  3. Throws a descriptive error (never silently returns undefined) if validation fails.
Import helpers from @umbra-privacy/sdk/types:
import {
  createU64,
  createU256,
  createBn254FieldElement,
  createCurve25519FieldElement,
  createPoseidonKey,
  createRescueCipherPlaintext,
} from "@umbra-privacy/sdk/types";
These six are the only create* brand helpers the SDK exports. Other branded types (U128, RescueCipherCiphertext, RescueCipherKey, OptionalData32, etc.) have an assert* predicate but no create* wrapper — for those, call the matching assert* and re-type, or rely on the SDK functions that produce them.

Mathematics Helpers

These are the helpers you will use most often. Token amounts, offsets, and protocol-level integers all go through here. Every create* helper (except createPoseidonKey) takes a single object argument{ value, name? }. The optional name is used only in the error message.
// createU64 — validate and brand a bigint as a 64-bit unsigned integer.
// Use for token amounts, fee values, and any U64 parameter.
const amount: U64 = createU64({ value: 1_000_000n, name: "amount" });

// Throws IntegerAssertionError for negative or oversized values:
createU64({ value: -1n });          // IntegerAssertionError
createU64({ value: 2n ** 64n });    // IntegerAssertionError
// createU256 — validate and brand a bigint as a 256-bit unsigned integer.
// Use for Stealth Pool Note commitment preimages, random generation seeds, and full-width scalars.
const commitment: U256 = createU256({ value: rawCommitmentBigint, name: "noteCommitment" });

Field Element Helpers

// createBn254FieldElement — validates that the value is in the BN254 scalar field.
// Required for Groth16 ZK proof public inputs and Poseidon hash inputs.
const publicInput: Bn254FieldElement = createBn254FieldElement({ value: leafHash, name: "leafHash" });
// createCurve25519FieldElement — validates that the value is in the Curve25519 field.
// Required for X25519 key derivation.
const privKey: Curve25519FieldElement = createCurve25519FieldElement({ value: rawKey, name: "x25519PrivateKey" });
// createPoseidonKey — validates a BN254 field element as a Poseidon cipher key.
// Keys are derived from the user's master seed; call this after derivation.
// NOTE: this helper is positional, not object-form: createPoseidonKey(value, name?).
const encKey: PoseidonKey = createPoseidonKey(derivedKeyBigint, "poseidonKey");

Rescue Cipher Helpers

The Rescue Cipher encrypts on-chain encrypted balances. Keeping plaintexts, ciphertexts, keys, and nonces as distinct types prevents the most dangerous class of encryption bug — passing a ciphertext where the decryptor expects a key. Only the plaintext brand ships a create* helper. The ciphertext, key, counter, and nonce brands are produced by the SDK’s cipher functions (or, when you must brand a raw value yourself, by their assert* predicates: assertRescueCipherCiphertext, assertRescueCipherKey, assertRescueCipherCounter, assertRescueCipherEncryptionNonce).
// createRescueCipherPlaintext — the unencrypted balance value before encryption.
const balance: RescueCipherPlaintext = createRescueCipherPlaintext({ value: rawBalance, name: "mxeBalance" });

Protocol Helpers

OptionalData32 and MicroLamportsPerAcu have no create* wrapper — they expose only an assert* predicate (positional (value, name?)). Assert, then the value is narrowed to the brand:
import { assertOptionalData32, assertMicroLamportsPerAcu } from "@umbra-privacy/sdk/types";

// assertOptionalData32 — validates a Uint8Array as a 32-byte optional metadata payload.
// Used as the optionalData field in deposit, withdraw, and Stealth Pool Note operations.
// MUST be a pre-hashed or pre-encrypted 32-byte value — never a plaintext orderId or identifier.
const noData = new Uint8Array(32);
assertOptionalData32(noData, "optionalData"); // noData is now OptionalData32

// assertMicroLamportsPerAcu — validates a bigint as a priority fee (a U64, so >= 0).
// Pass as the `microLamportsPerAcu` option on operation calls to speed up processing under network load.
const fee = 1000n;
assertMicroLamportsPerAcu(fee, "microLamportsPerAcu"); // fee is now MicroLamportsPerAcu

Limb Helper

Base85Limb likewise has only an assert* predicate (positional (value, name?)), no create* wrapper:
import { assertBase85Limb } from "@umbra-privacy/sdk/types";

// assertBase85Limb — validates a bigint as an 85-bit limb (0 to 2^85 - 1).
// Used internally when decomposing 256-bit values into three limbs for ZK circuit inputs.
const limb0 = value & ((1n << 85n) - 1n);
const limb1 = (value >> 85n) & ((1n << 85n) - 1n);
const limb2 = value >> 170n;
assertBase85Limb(limb0, "limb0");
assertBase85Limb(limb1, "limb1");
assertBase85Limb(limb2, "limb2");

Common Patterns

Converting a user-supplied number to U64

User-facing forms and external APIs often produce plain number or string. Always validate before passing to the SDK:
import { createU64 } from "@umbra-privacy/sdk/types";

// User input from a form field (string)
const rawInput = "1000000"; // 1 USDC (6 decimals)
const amount: U64 = createU64({ value: BigInt(rawInput), name: "depositAmount" });

// Call the SDK function — amount is now type-safe
const signature = await deposit("DestinationAddress", mint, amount);

Reading on-chain data and re-branding

Account data fetched via RPC arrives as raw bigint. Validate before any cryptographic operation. RescueCipherCiphertext has no create* wrapper, so use its assert* predicate:
import { assertRescueCipherCiphertext } from "@umbra-privacy/sdk/types";

// rawAccount.ciphertext is bigint from on-chain deserialization
const ciphertext = rawAccount.ciphertext;
assertRescueCipherCiphertext(ciphertext, "onChainCiphertext");
// ciphertext is now RescueCipherCiphertext

The assert* vs create* distinction

The SDK exports both families. Use create* (not assert*) in expression contexts — assert* returns void and forces a two-step pattern:
// ❌ Verbose — requires a temporary variable and an unsafe cast
assertU64(rawAmount, "amount");
const amount = rawAmount as unknown as U64;

// ✓ Concise — validates and brands in a single expression
const amount = createU64({ value: rawAmount, name: "amount" });

Error handling

All helpers throw a typed error on failure. Catch at the boundary where you receive external input:
import { createU64 } from "@umbra-privacy/sdk/types";
import { IntegerAssertionError } from "@umbra-privacy/sdk/errors";

try {
  const amount = createU64({ value: BigInt(userInput), name: "amount" });
  await deposit(destination, mint, amount);
} catch (err) {
  if (err instanceof IntegerAssertionError) {
    // Show a validation message to the user
    setError(`Invalid amount: ${err.message}`);
  } else {
    throw err;
  }
}

Passing branded values through the SDK

Once a value is branded, you can pass it directly to any SDK function that accepts that type — no additional wrapping needed:
import { createU64, createU256 } from "@umbra-privacy/sdk/types";

const amount: U64 = createU64({ value: depositAmountBigint, name: "amount" });
const seed: U256 = createU256({ value: randomSeedBigint, name: "randomSeed" });

// Both are now type-safe for use in SDK operations
const signature = await createSelfBurnableNote({ amount, mint, destinationAddress }, { generationIndex: seed });

What the SDK Does Not Accept

You cannot pass a plain bigint where a branded type is required — the TypeScript compiler will reject the call:
const raw: bigint = 1_000_000n;

// Error: Argument of type 'bigint' is not assignable to parameter of type 'U64'
await deposit(destination, mint, raw);

// Correct:
await deposit(destination, mint, createU64({ value: raw, name: "amount" }));
This is intentional. The validation the create* helpers perform is not optional — it is the mechanism that guarantees protocol invariants hold before values reach the on-chain program.