Verifying Disclosure Keys
A user can hand an auditor, an exchange or a quest verifier an orbdisc: key that discloses one
note: its value, asset, owner key and blinding. Checking it needs only @orbinum/protocol and a
node; it needs no key and no special access. This page shows the check end to end. The key format
is in Disclosure Key Format; how a user creates and shares one is
in Note Disclosure.
What a check proves
| A passing check proves | It does not prove |
|---|---|
The key's fields are in range and hash to its commitment: Poseidon4(value, assetId, ownerPk, blinding) = c | That the person who handed you the key owns the note. The sender of a note knows the same preimage and can produce the same key |
| The pool holds that commitment, at a given leaf index | That the note is unspent. A spend reveals a nullifier, which cannot be linked to the commitment without the owner's spending key |
| The note was created with exactly that value of that asset | Anything about the user's other notes. Each note has its own owner key, so two keys from one person show unrelated opk values |
Treat a passing key as evidence that a note with these contents exists in the pool, nothing more. Where ownership matters, combine it with something only the owner can do.
How the check runs
- Decode and verify locally.
decodeNoteDisclosureKey(key)parses the key, checks every field against its range (valuea u128,assetIda u32, the rest below the BN254 field order), and recomputes the Poseidon commitment. It returns the fields asbigints, ornullfor any failure. This step needs no network. - Encode the commitment. The key holds the commitment as a big-endian number; the chain stores
it as 32 little-endian bytes.
commitmentHexOf(disclosure.commitment)converts it. - Look it up.
client.privacy.getMerkleProofByCommitment(hex)returns the leaf index, or fails with-32602when the pool does not hold the commitment.
The reasons each step exists, including why the range checks matter, are in How verification works.
Example
import {
OrbinumClient,
commitmentHexOf,
decodeNoteDisclosureKey,
formatBalance,
isServerBusyError,
} from '@orbinum/protocol';
type Verdict =
| { status: 'invalid' }
| { status: 'not-in-pool'; commitment: string }
| {
status: 'in-pool';
commitment: string;
leafIndex: number;
value: bigint;
assetId: bigint;
};
export async function verifyDisclosureKey(client: OrbinumClient, key: string): Promise<Verdict> {
// Steps 1 and 2, offline: range checks and Poseidon4(val, aid, opk, bld) == c.
const disclosure = decodeNoteDisclosureKey(key.trim());
if (disclosure === null) return { status: 'invalid' };
// Step 3: the chain stores the commitment as 32 little-endian bytes.
const commitment = commitmentHexOf(disclosure.commitment);
try {
const { leafIndex } = await client.privacy.getMerkleProofByCommitment(commitment);
return {
status: 'in-pool',
commitment,
leafIndex,
value: disclosure.value,
assetId: disclosure.assetId,
};
} catch (error) {
// -32602: the pool holds no such commitment.
if ((error as { code?: number }).code === -32602) return { status: 'not-in-pool', commitment };
// A busy node or a dropped connection says nothing about the note.
if (isServerBusyError(error)) throw new Error('node busy, retry shortly', { cause: error });
throw error;
}
}
const client = await OrbinumClient.connect({ substrateWs: 'wss://rpc-1.testnet.orbinum.io' });
try {
const verdict = await verifyDisclosureKey(client, process.argv[2] ?? '');
if (verdict.status === 'in-pool') {
console.log(
`In the pool at leaf ${verdict.leafIndex}: ${formatBalance(verdict.value)} (asset ${verdict.assetId})`,
);
} else {
console.log(verdict);
}
} finally {
client.destroy();
}
Node 22.18 or later runs it directly from a package whose package.json sets "type": "module":
node verify-disclosure.ts 'orbdisc:eyJ2IjoxLCJjIjoi…'
For a malformed or edited key it prints { status: 'invalid' }. For a well-formed key whose
commitment is not in the pool it prints the commitment it looked for:
{
status: 'not-in-pool',
commitment: '0xa78eebab6fe66b9546ea60a46a548286244f16a680197a009a57fba956ba0a2d'
}
formatBalance assumes 18 decimals and the ORB symbol, which is right for asset 0.
Handling the result
| Result | Meaning | What to do |
|---|---|---|
invalid | The key is malformed, has an unknown version, carries an out-of-range field, or its fields do not hash to its commitment | Reject it. decodeNoteDisclosureKey returns null for every one of these, so there is one reason to report: the key does not verify |
not-in-pool | The preimage is consistent, but no leaf holds that commitment | Reject it, or retry later if the user says the note was created moments ago |
in-pool | The note exists with these contents | Accept it as evidence of the note, within the limits in What a check proves |
| An error is thrown | The node was busy or unreachable | Retry. Never read it as not-in-pool |
Strip whitespace before decoding, since keys are pasted, and refuse anything longer than
1,024 characters, which decodeNoteDisclosureKey already rejects.
The lookup reads the node's best block. A note in a block that is not yet final shows as in-pool;
to rely only on final state, check that its block is finalized, for example from the leaf's
Shielded or CommitmentsInserted event (see Indexing Events).
Testing with a key you create
createNoteDisclosureKey builds a key from a preimage. It throws a RangeError for a field out of
range and does not check the commitment, so a test can produce both kinds of key:
import { createNoteDisclosureKey, decodeNoteDisclosureKey } from '@orbinum/protocol';
import { poseidon4 } from 'poseidon-lite';
const value = 100_000n * 10n ** 18n;
const assetId = 0n;
const ownerPk = 0x1d4a09a1ab9ffff52fbef32c393617d70c03c92835bc6331fbb4a45e381d9735n;
const blinding = 0x2dcbb6f4d229a19c91e1a4ee057bafa3a96573fa36559644864c811b13ae8d62n;
const commitment = poseidon4([value, assetId, ownerPk, blinding]);
const good = createNoteDisclosureKey({ commitment, value, assetId, ownerPk, blinding });
const edited = createNoteDisclosureKey({ commitment, value: value + 1n, assetId, ownerPk, blinding });
console.log(decodeNoteDisclosureKey(good) !== null); // true: consistent preimage
console.log(decodeNoteDisclosureKey(edited)); // null: the value no longer hashes to c
good passes the local check and then reports not-in-pool, because no one shielded that note.
What the lookup reveals
The lookup sends one commitment to the node, which tells the node that someone is checking that note. The user revealed the note to you already; the query reveals it to the node operator too. Run your own node to keep it between you and the user. See Reading Pool State.
Related
- Disclosure Key Format — the
orbdisc:format, byte order and verification rules - Note Disclosure — creating and sharing a key in the app
- Notes — what the four committed fields are
- Reading Pool State — the privacy RPC and its errors
- @orbinum/protocol — the package reference