Skip to content

UMBR is live on testnet. Private payments with test funds on Robinhood Chain and Tempo.

Open the app

Build

SDK

@umbr/sdk gives any TypeScript app or agent a private balance, private payments, payroll and scoped proofs. Proofs are made on the caller's machine.

Package
@umbr/sdk
Status
Preview, not on npm yet
Runs in
Browser and Node
Networks
Tempo Moderato, Robinhood testnet

Preview API

@umbr/sdk is not published to npm yet and its API may change before the first release. Everything on this page targets testnets. Nothing here moves real money.

What the SDK gives you#

The SDK is the same engine the UMBR app uses, packaged for your own code. With one client object you can:

  • Hold a private balance. Shield public stablecoins into the pool and unshield them back out.
  • Pay privately. Send to an umbr1q... address, or create a claim link for someone who does not have one yet.
  • Run payroll. Pay many people in one run from a list or a CSV, once or on a schedule.
  • Prove one fact. Make a scoped proof of a balance, a payment or a payroll total, and check proofs other people send you.
  • Set limits. Cap what a client may spend per payment and per day, which matters most when the caller is an agent.

Install#

The SDK uses viem for chain access and wallet signing, so install both:

$ pnpm add @umbr/sdk viem

It ships as ES modules with TypeScript types. Proving runs in WebAssembly, so it works in modern browsers and in Node 20 or later. The first proof downloads the circuit files once and caches them.

Networks#

Pass one of the exported networks to the client. A network is usable only when its pool address and start block are configured; until then it is read-only and calls that write return POOL_NOT_CONFIGURED.

ExportChainChain idStablecoinsStatus
networks.tempoModeratoTempo testnet (Moderato)42431pathUSD, AlphaUSD, BetaUSD, OUSDTestnet
networks.robinhoodTestnetRobinhood Chain testnet46630USDGTestnet
networks.tempo, networks.robinhoodTempo, Robinhood Chain4217, 4663Per chainNot configured

Mainnet entries exist in the type but stay unusable until a pool is deployed and configured for them. UMBR is not live on any mainnet. See Networks for RPCs and explorers.

The model#

You do not need the cryptography to use the SDK, but four ideas explain everything it does.

  • Notes. Your private balance is a set of notes. Each note says "this much of this asset belongs to this key". Only you can read your notes.
  • Commitments. The pool contract stores a fingerprint of each note, never the note itself. The chain sees that a note was added, not what is in it.
  • Nullifiers. Spending a note publishes a one-time marker that stops it being spent twice. The marker cannot be traced back to the note it came from.
  • Proofs. Each spend comes with a zero-knowledge proof that the rules were followed: you own the notes, the amounts add up, nothing was spent twice.

Two things follow. Your keys stay local: the client derives them from the wallet or signer you pass in and never sends them anywhere. And proofs are made on the caller's machine: the browser tab or server process running the SDK does the proving, so no UMBR server sees your notes. The longer version is in How shielding works.

A first private payment#

Create a client. In a browser, hand it a viem wallet:

client.ts
import { createUmbrClient, networks } from "@umbr/sdk";
import { createWalletClient, custom } from "viem";

// In a browser: the user's own wallet signs, keys stay on their device.
const wallet = createWalletClient({ transport: custom(window.ethereum) });

const umbr = createUmbrClient({
	network: networks.tempoModerato,
	wallet,
});

console.log(umbr.address); // "umbr1qexample...you"

On a server, use a signer key from your secret store and a file-backed note store:

server.ts
import { createUmbrClient, fileStore, networks } from "@umbr/sdk";

// On a server or in a worker: a key from your secret store.
const umbr = createUmbrClient({
	network: networks.robinhoodTestnet,
	signer: process.env.UMBR_KEY,
	storage: fileStore("./.umbr"), // where notes are kept on disk
});

Then shield, send, check and unshield:

pay.ts
// 1. Shield: public pathUSD becomes a private note you own.
const { notes } = await umbr.shield({ asset: "pathUSD", amount: "250.00" });

// 2. Send: a proof is made here, then the payment is submitted.
const { paymentId } = await umbr.send({
	to: "umbr1qexample...ana",
	asset: "pathUSD",
	amount: "40.00",
	memo: "Invoice 0192", // encrypted for the recipient only
});

// 3. Read what is left.
await umbr.balance("pathUSD"); // "210.00"

// 4. Unshield some of it to a public wallet when you need to.
await umbr.unshield({ asset: "pathUSD", amount: "60.00", to: "0xYourPublicWallet" });

Amounts are decimal strings, never floats, so "40.00" means forty pathUSD whatever the token's decimals are. The Quickstart walks through the same steps with test funds.

Receiving payments#

Incoming payments are notes encrypted to your key. The client finds them when it syncs with the pool, then emits a payment event for each one. Sync on a timer, on page focus, or before you read a balance.

receive.ts
umbr.on("payment", (p) => {
	// { paymentId, asset, amount, memo, from: undefined }
	console.log(`Received ${p.amount} ${p.asset}`, p.memo ?? "");
	markInvoicePaid(p.memo, p.amount);
});

// Pull new pool activity and decrypt anything addressed to you.
// Payment events fire during sync.
await umbr.notes.sync();

const unspent = (await umbr.notes.list({ asset: "pathUSD" })).filter((n) => !n.spent);

The from field is always undefined: a private payment does not tell the recipient who sent it. Use the memo, which only the recipient can read, to match payments to invoices.

Payroll from code#

A run pays every line from your private balance. Each recipient sees only their own line, and the public sees pool interactions without amounts or recipients.

payroll.ts
import { readFile } from "node:fs/promises";

// CSV columns: address,amount[,label]
const lines = umbr.payroll.parseCsv(await readFile("./october.csv", "utf8"));

const run = await umbr.payroll.run({ asset: "USDG", lines });
console.log(run.id, run.txHashes.length);

// Or repeat it every month from a start date.
await umbr.payroll.schedule({
	asset: "USDG",
	lines,
	every: "month",
	startAt: "2026-11-01T09:00:00Z",
});

Large runs are split into several transactions, which is why txHashes is a list. If a run stops part way, the returned lines say which ones settled. See Private payroll for the full picture.

Proofs from code#

umbr.prove makes the same four proofs as the app: exact balance, at least, payment and payroll total. Each one names an audience and an expiry of 1, 7 or 30 days.

prove.ts
import { verifyProof } from "@umbr/sdk";

const proof = await umbr.prove({
	claim: "payroll-total",
	run: run.id,
	audience: "accounting@yourco.example",
	expiresIn: "30d",
});

// Send proof.link or proof.json to the reader. They (or you) can check it:
const result = await verifyProof(proof.link);
// { valid: true, claim: "payroll-total", statement: "...", audience: "...", expired: false }

verifyProof is a standalone export, so a system that only checks proofs does not need a client or a wallet. More in Selective disclosure and Proofs.

Using a relayer#

Without a relayer, your own wallet submits each private transaction and pays its fee, so your public address shows up next to the pool interaction. A relayer submits the proof for you instead.

relayer.ts
const umbr = createUmbrClient({
	network: networks.tempoModerato,
	wallet,
	relayer: "default",              // the UMBR relayer
	// relayer: "https://relay.example.com", // one you run yourself
	// relayer: false,               // submit from your own wallet
});

The destination and the relayer fee are bound inside the proof, so a relayer cannot redirect funds or take more than the agreed fee. It can still refuse or delay a transaction, and it sees your IP address and timing. A rejected submission throws RELAYER_REJECTED; you can retry with another relayer or with relayer: false.

Guardrails#

Set limits

limits are checked before any proof is made. A call over the limit throws LIMIT_EXCEEDED and nothing is submitted.

limits.ts
import { UmbrError } from "@umbr/sdk";

const umbr = createUmbrClient({
	network: networks.tempoModerato,
	signer: process.env.UMBR_KEY,
	limits: { perPayment: "500.00", perDay: "5000.00" },
});

try {
	await umbr.send({ to, asset: "pathUSD", amount: "900.00" });
} catch (err) {
	if (err instanceof UmbrError && err.code === "LIMIT_EXCEEDED") {
		// Nothing was proved or submitted. Ask a human.
	}
	throw err;
}

Never ship a signer to the browser

signer is a raw private key and belongs only in server code that reads it from a secret store. In a browser, always pass a wallet so the user's own wallet signs. A key bundled into front-end code is a key anyone can read.

Back up note storage

Notes can be rebuilt from the chain with your keys, but a sync from scratch is slow, and memos and payroll history live only in the store. Back up the fileStore folder on servers. In browsers, the default IndexedDB store is cleared if the user wipes site data.

Treat the API as a preview

Pin an exact version once it is published, read release notes before upgrading, and keep a testnet run in your CI so an API change fails a build rather than a payment.