Skip to content

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

Open the app

Build

Selective disclosure

Prove one fact about your private money to one reader, for a limited time, without opening the rest of your history.

Proof kinds
4
Made
On the caller's machine
Expiry
1, 7 or 30 days
Checked at
/verify or verifyProof

Why it matters#

Private money still has to answer questions. A landlord wants proof of funds. A supplier wants to see that an invoice was paid. An accountant needs this month's payroll total. On a public chain, answering any of these usually means sharing an address, and an address shows every balance and every payment it has ever made.

Selective disclosure answers the question that was asked and nothing else. The reader gets one statement they can check for themselves, and your history stays where it was.

What a disclosure says#

Every disclosure carries the same parts, and the reader sees all of them:

PartExampleNotes
Claimbalance-at-leastOne of exact balance, at least, payment, payroll total
StatementHolds at least 6,000.00 USDGThe single fact being proved
AudienceNorthwind LeasingFree text you choose: a name, a company, an email
Expiry7 days after creation1, 7 or 30 days
NetworkRobinhood Chain testnetThe pool the proof was checked against

It does not contain your public wallet, your UMBR address, your other balances or any other payment. For what each kind shows and hides, see Proofs.

How it works#

From your notes to a checked statement
  1. You pick a claim

    Choose the kind of proof, the amount or floor, the audience and the expiry.
  2. The SDK selects notes locally

    It reads your synced notes and picks the ones that support the claim. They never leave the machine.
  3. A zero-knowledge proof is made

    The proof shows the notes exist in the pool's Merkle tree and satisfy the claim. Audience and expiry are inputs, so they cannot be edited afterwards.
  4. You share the result

    A link or a JSON file. Nothing is posted on chain and no UMBR server stores it.
  5. The reader verifies

    The proof is checked against the published verification key, and its Merkle root is checked against roots the pool has recorded.

Create a disclosure#

Sync first so the proof uses your current notes, then call umbr.prove with the claim you need:

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

const umbr = createUmbrClient({ network: networks.robinhoodTestnet, wallet });
await umbr.notes.sync(); // prove from up-to-date notes

// Proof of funds for a lease application.
const funds = await umbr.prove({
	claim: "balance-at-least",
	asset: "USDG",
	amount: "6000.00",
	audience: "Northwind Leasing",
	expiresIn: "7d",
});

// A single payment, revealing only a floor.
const paid = await umbr.prove({
	claim: "payment",
	payment: paymentId,
	reveal: "at-least",
	amount: "300.00",
	audience: "billing@vendor.example",
	expiresIn: "30d",
});

// The total of one payroll run, for the books.
const total = await umbr.prove({
	claim: "payroll-total",
	run: runId,
	audience: "accounting@yourco.example",
	expiresIn: "30d",
});

An at-least proof can draw on up to four notes in the same asset. If your balance is spread across more, send to yourself to merge notes first, or prove a lower floor. A payroll-total proof works only for runs you sent.

Share it#

prove returns a Proof in three forms of the same thing. Use link for people, json for systems, and text when you need one line you can paste anywhere.

The file format

A disclosure is a JSON object whose format field is umbr.disclosure/1. It names the network (chain id) and the pool, the kind of claim (funds, balance, payment or payroll), the asset and amount (plus a count for payroll), the audience, issue and expiry times, the public inputs and a Groth16 proof on BN254. The one-line form is the same JSON, base64url-encoded, behind the prefix umbrproof1:. The SDK and the verify page accept either.

share.ts
// A link: easiest for a person. Opens umbr.trade/verify with the proof in it.
await sendEmail({ to: "leasing@northwind.example", body: funds.link });

// JSON: easiest for a system. Attach it or post it to their API.
await fetch("https://intake.example/proofs", {
	method: "POST",
	headers: { "content-type": "application/json" },
	body: funds.json,
});

Choose the audience and expiry with care

  • Make the audience specific. "Northwind Leasing, unit 4B application" says more than "landlord" if the proof ever turns up somewhere else.
  • Use the shortest expiry that fits. A proof is a snapshot; a balance can change the next minute. One day is right for most proof-of-funds checks; thirty days suits an audit window.
  • Treat a proof like a signed letter. Anyone you forward it to can read it.

Why both fields matter is covered in Made for one person.

Verify a disclosure#

A reader needs no account and no wallet. A person can paste the link or the file at umbr.trade/verify. A system can call verifyProof, which is exported on its own so you do not need a client:

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

export async function acceptProofOfFunds(input: string, minimum: number) {
	const r = await verifyProof(input); // a link or the JSON

	if (!r.valid) return { ok: false, reason: "invalid proof" };
	if (r.expired) return { ok: false, reason: "proof expired" };
	if (r.claim !== "balance-at-least") return { ok: false, reason: "wrong kind of proof" };
	if (r.audience !== "Northwind Leasing") return { ok: false, reason: "made for someone else" };

	return { ok: true, statement: r.statement, expiresAt: r.expiresAt };
}

What the verify page checks today

umbr.trade/verify reads the file, checks the network, the pool address, the claim, the expiry and that the Groth16 proof and public inputs are well-formed numbers. The pairing check against the pool's on-chain verifier is reported as "unknown" until UMBR's contracts are configured for that network, so treat a result there as well-formed, not verified, until it says otherwise.

Check every field you rely on: validity, expiry, the kind of claim and the audience. An invalid proof returns valid: false rather than throwing. For a payroll total, the reader also gets the number of payments; see Payroll total for what the checker rejects so a total cannot be padded.

Guarantees and limits#

What a valid proof does guarantee

  • The statement was true for notes in the pool when the proof was made.
  • The audience and expiry are the ones chosen by the person who made it.
  • Nothing beyond the statement is revealed by the proof itself.

What it does not

  • It does not say the funds are still there. Expiry bounds how stale a proof can be, nothing more.
  • Audience is a label, not access control. A forwarded proof still verifies; it just shows who it was for.
  • A payment proof shows a payment happened inside the pool, not what it was for.
  • Soundness rests on the circuits and their setup. UMBR runs on testnets today; read the threat model and the production gate.

Revealing more than you meant

A proof cannot leak your history, but the context around it can. Sharing an exact balance right after a payment you also disclosed can let a reader connect the two. When in doubt, prove a floor.