voidcashdocs

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

FieldTypeMeaning
amountbelow 2^64Lamports held by the note
pubKeyfield elementThe owner's note public key, Poseidon(spendKey). See Keys
blindingbelow 2^24831 random bytes chosen by the note's creator, so it fits the ciphertext
assetconstant 0Native 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
PartPurpose
Ephemeral public keyFresh for every note, so two notes to the same address share nothing visible
Zero nonceEach key encrypts exactly one message, so a fixed nonce is never reused under a key
amount, blindingWith 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.

ActionInputsOutputsCiphertexts
Shield2 dummiesthe new note, a zero notethe new note to its owner (the vault, or any void address), random bytes
Private transfer1 or 2 notesthe recipient's note, the changeto the recipient, to the sender
Unshield1 or 2 notesthe change, a zero noteto the sender, random bytes
Merge2 notesone note, a zero noteto 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 inputs4 inputs8 inputs
Constraints15,36230,07759,519
Transaction size (limit 1,232 bytes)952 to 984 bytes, measuredabout 1,114 bytesabout 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.

On this page