Skip to main content

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 provesIt does not prove
The key's fields are in range and hash to its commitment: Poseidon4(value, assetId, ownerPk, blinding) = cThat 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 indexThat 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 assetAnything 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​

  1. Decode and verify locally. decodeNoteDisclosureKey(key) parses the key, checks every field against its range (value a u128, assetId a u32, the rest below the BN254 field order), and recomputes the Poseidon commitment. It returns the fields as bigints, or null for any failure. This step needs no network.
  2. 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.
  3. Look it up. client.privacy.getMerkleProofByCommitment(hex) returns the leaf index, or fails with -32602 when 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​

ResultMeaningWhat to do
invalidThe key is malformed, has an unknown version, carries an out-of-range field, or its fields do not hash to its commitmentReject it. decodeNoteDisclosureKey returns null for every one of these, so there is one reason to report: the key does not verify
not-in-poolThe preimage is consistent, but no leaf holds that commitmentReject it, or retry later if the user says the note was created moments ago
in-poolThe note exists with these contentsAccept it as evidence of the note, within the limits in What a check proves
An error is thrownThe node was busy or unreachableRetry. 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.