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
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 viemIt 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.
| Export | Chain | Chain id | Stablecoins | Status |
|---|---|---|---|---|
networks.tempoModerato | Tempo testnet (Moderato) | 42431 | pathUSD, AlphaUSD, BetaUSD, OUSD | Testnet |
networks.robinhoodTestnet | Robinhood Chain testnet | 46630 | USDG | Testnet |
networks.tempo, networks.robinhood | Tempo, Robinhood Chain | 4217, 4663 | Per chain | Not 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:
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:
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:
// 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.
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.
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.
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.
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.
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.