Notes, commitments and nullifiers
What a note contains, how its commitment and nullifier are computed, how notes are encrypted and found, and why every transaction has two inputs and two outputs.
A note is an amount of SOL in the pool, owned by one vault. The chain never sees a note: the program stores only its commitment in the Merkle tree, next to an encrypted copy for its owner. Spending a note publishes its nullifier, which marks it spent without saying which commitment it was.
Structure
| Field | Type | Meaning |
|---|---|---|
amount | below 2^64 | Lamports held by the note |
pubKey | field element | The owner's note public key, Poseidon(spendKey). See Keys |
blinding | below 2^248 | 31 random bytes chosen by the note's creator, so it fits the ciphertext |
asset | constant 0 | Native SOL. Fixed by the circuit, not stored in the ciphertext |
To spend a note its owner also needs the note's leaf index in the tree and the vault's spendKey.
Hashes
Poseidon is circomlib's over the BN254 scalar field. The circuit, the app (through poseidon-lite) and the program (the
sol_poseidon syscall) compute the same values.
commitment = Poseidon(amount, pubKey, blinding, asset) asset = 0 for SOL
nullifier = Poseidon(commitment, index, Poseidon(spendKey, commitment, index))The inner hash binds the spend key: nobody else can compute a note's nullifier, even with the note's contents. The leaf index makes nullifiers positional. A sender who creates two notes with the same amount, owner and blinding gets two equal commitments at different leaves, and they still have different nullifiers, so both can be spent.
The program creates one account per nullifier, at the PDA ["nf", nullifier] (32 bytes, big-endian). A second spend
of the same note tries to create the same account and fails.
The asset field
asset is in the commitment so that a later circuit with more assets can spend today's notes as asset 0, in the same
tree, without a migration. The current circuit fixes it to 0 in every note it reads or writes, as a constant rather
than a signal. It costs 132 constraints. See Roadmap.
Encryption
Every output of a transact carries an 88-byte ciphertext in the instruction data. A note is encrypted to its owner's
viewing public key:
esk, epk = a fresh X25519 key pair
shared = X25519(esk, viewPublic)
key = HKDF-SHA256(ikm = shared, salt = epk, info = "voidcash/v1/note", 32 bytes)
plaintext = 0x01 (version) ‖ amount (u64, little-endian) ‖ blinding (31 bytes, big-endian) 40 bytes
ciphertext = epk (32) ‖ XChaCha20-Poly1305(key, nonce = 24 zero bytes).encrypt(plaintext) 32 + 40 + 16 = 88 bytes| Part | Purpose |
|---|---|
| Ephemeral public key | Fresh for every note, so two notes to the same address share nothing visible |
| Zero nonce | Each key encrypts exactly one message, so a fixed nonce is never reused under a key |
amount, blinding | With the owner's own pubKey, everything needed to rebuild the commitment |
The sender's change note uses the same scheme, encrypted to the sender's own viewing key. There is no view tag: a wallet tries every ciphertext.
Finding notes
The app walks every leaf the indexer serves. For each one it tries to decrypt the ciphertext with the vault's viewing secret, and accepts the note only if:
- the ciphertext is 88 bytes, decrypts, and has version 1;
- the amount is not zero;
Poseidon(amount, notePubKey, blinding, 0)equals the leaf's commitment.
A ciphertext that does not match its commitment is ignored, so a bogus ciphertext cannot fake a note. The program does
not read ciphertexts. They are bound to the proof through extDataHash (see The circuit), so
a relayer cannot swap them, but a sender can put anything in them; the recipient then cannot find that note.
A found note is unspent while its nullifier is not in the pool's nullifier set.
Two inputs, two outputs
Every transact spends two inputs and creates two outputs. The missing ones are dummies.
- Dummy input: amount 0, a random spend key, a random blinding, index 0, an all-zero path. The circuit skips the Merkle check for an input of amount 0 but still checks its nullifier. The nullifier comes from a fresh random key, so it is unique and looks like any other. Its account is created like any other.
- Zero output: a note of amount 0 to the creator's own
pubKey, with a random blinding. Its commitment enters the tree like any other, and its ciphertext slot carries 88 random bytes.
So every transaction shows two nullifiers, two commitments and two ciphertexts of the same length.
| Action | Inputs | Outputs | Ciphertexts |
|---|---|---|---|
| Shield | 2 dummies | the new note, a zero note | the new note to its owner (the vault, or any void address), random bytes |
| Private transfer | 1 or 2 notes | the recipient's note, the change | to the recipient, to the sender |
| Unshield | 1 or 2 notes | the change, a zero note | to the sender, random bytes |
| Merge | 2 notes | one note, a zero note | to the vault itself, random bytes |
The two inputs must have different nullifiers: the circuit refuses a note spent twice in one proof.
Why two inputs
A wider circuit was measured and not adopted (before the asset field was added):
| 2 inputs | 4 inputs | 8 inputs | |
|---|---|---|---|
| Constraints | 15,362 | 30,077 | 59,519 |
| Transaction size (limit 1,232 bytes) | 952 to 984 bytes, measured | about 1,114 bytes | about 1,374 bytes: does not fit |
Each extra input adds 65 bytes: 32 of instruction data, a new nullifier account and its index. Every input, dummies included, creates a nullifier account whose rent stays locked, so a wider circuit would make every transaction pay more rent.
With two inputs, a balance spread over many notes is combined first. Before a send or an unshield that no two notes cover, the app merges notes through the relayer: each merge is a private transfer to the same vault, two notes into one, paying only the relayer fee. Merges run in rounds of disjoint pairs, all proven against one root. On-chain they are ordinary private transfers.
Keys and addresses
The vault root, the spend and viewing keys derived from it, the void address and recovery key formats, and how the root is sealed in the browser.
The Merkle tree
The append-only Poseidon tree of note commitments, its zero values, the per-slot root history, and the layout of the tree account.