noircashdocs

Notes, commitments and nullifiers

What a note contains, how its commitment and nullifier are computed, how notes are encrypted, and how shielded notes get their shares.

A note is a private amount of vault shares owned by one key. The chain never sees a note: it stores only the note's commitment in the Merkle tree, next to an encrypted copy of the note for its owner. Spending a note publishes its nullifier, which marks it spent without saying which commitment it was.

Structure

FieldTypeMeaning
opkfield elementOwner public key, from the registry: H(nk, x_hi, x_lo, y_hi, y_lo). See Keys
rhofield elementRandom value chosen by the note's creator
rfield elementRandom blinding value
sharesat most 120 bitsVault shares held by the note. See The vault

The spender also needs the note's leaf index and the nullifier key nk, which are not part of the note.

Hashes

H is Poseidon2 over the BN254 scalar field, the sponge that Noir's Poseidon2::hash(inputs, inputs.len()) computes (noir-lang/poseidon v0.3.0). The circuit, the vault and the app use the same definitions.

p      = 0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001   (BN254 scalar field)

stub   = H(opk, rho, r)
cm     = H(1, stub, shares)          1 = DOMAIN_NOTE
nf     = H(2, nk, cm, index)         2 = DOMAIN_NULLIFIER, index = leaf index in its tree
node   = H(left, right)              Merkle tree node

The stub exists so a note can be created before its share amount is known. shield(stub, amount, ciphertext) takes only the stub; the vault computes the shares from the amount at execution time and completes cm = H(1, stub, shares) itself. That is how PrivateRouter.buy trades and shields in one transaction.

The vault rejects any stub, commitment, nullifier or exit amount that is not below p (NotAFieldElement).

Dummies

A transact always has exactly two inputs and two outputs. Missing ones are dummies:

  • Dummy input: shares = 0, random nk, rho and r, index 0. The circuit skips the membership check for a zero-value input, but still requires its nullifier to be H(2, nk, cm, 0). With a random nk the nullifier is unique, and it is indistinguishable from a real one.
  • Dummy output: a random stub with shares = 0, and random bytes of a ciphertext's length, so it looks like any other output.

Both nullifiers of a transaction must be different (DuplicateNullifier) and unspent (NullifierAlreadySpent).

Encryption

Every note created by shield or transact carries a ciphertext for its owner, emitted in NoteAdded(index, commitment, ciphertext). The app encrypts to the owner's X25519 viewing key:

shared  = X25519(ephemeral secret, recipient view public key)
key     = HKDF-SHA256(ikm = shared, salt = ephemeral public key, info = "noircash/v1/note-key", 32 bytes)
tag     = SHA-256(shared || "noircash/v1/view-tag")[0]
body    = XChaCha20-Poly1305(key, nonce).encrypt(rho || r || shares || memo)    32 bytes each, big-endian; memo 117

ciphertext = ephemeral public key (32) || tag (1) || nonce (24) || body (96 + 117 + 16)   = 286 bytes
PartPurpose
Ephemeral public keyFresh per note, so two notes to the same key share nothing visible
View tag (1 byte)Lets a wallet skip 255 of 256 foreign notes without trying to decrypt
rho, r, sharesEverything needed to rebuild the note. opk is implied by the view key
Memo (117 bytes)In the sender's own change note, what the spend did (see below). All zero in every other note

A wallet accepts a decrypted note only if H(1, H(opk, rho, r), shares) equals the commitment in the event, so a bogus ciphertext cannot fake a note. Ciphertexts of other lengths are ignored. The vault does not check ciphertexts: the ones in a transact are bound to the proof through extDataHash (see The circuit), so a bundler cannot swap them, but a sender can put garbage in them. A recipient then cannot find that note.

Spend memo and proof of payment

Every spend creates a change note back to the sender, even when the change is zero, and its memo records the spend:

memo = kind (1) || address (20) || rho (32) || r (32) || shares (32)
kind: 1 private send (address = recipient; rho, r, shares = the recipient's note)
      2 unshield (address = recipient of the public NOIR)
      3 sale (address = recipient of the ETH)

Only the sender's viewing key opens it, so their history, rebuilt on any device from the signature, shows whom each spend paid. The recipient's note carries an all-zero memo of the same length: it learns nothing about the sender, and on-chain every output ciphertext has the same length.

For a private send, the memo holds the opening of the recipient's note, which is the leaf just before the change note. The sender can share it as a proof of payment (noircash-receipt-v1.…). Anyone can check it against the chain: the transaction created a note at that index whose commitment equals H(1, H(opk, rho, r), shares), with opk the recipient's registered key at that block. It proves who received the note and its value, and nothing else: spending the note, or telling when it is spent, needs the recipient's nk. The app verifies proofs at /verify.

Shielded notes

A note created by shield, directly or through PrivateRouter.buy, has an owner-chosen stub. Its shares are only known on-chain, so the ciphertext carries shares = 0. The vault emits Shielded(from, index, amount, shares), and index names the leaf it created. The wallet matches the NoteAdded and the Shielded events by transaction hash and leaf index, whatever the log order, and takes the shares from Shielded.

Who shielded (from: the holder, or the router for a private buy) and how much is public. Which later spend consumed the note is not, because rho and r are secret.

shield is the only way into the private side. NOIR sent to the vault's address with a plain ERC-20 transfer creates no note: the next absorb() adds it to the backing B as a donation to every note, so the sender cannot get it back.

Faerie gold and positional nullifiers

A faerie gold attack gives a recipient two notes that look valid but can only be spent once. If the nullifier depended only on the note's contents, a sender could create two notes with the same stub and the same shares. Both would have the same commitment and the same nullifier: spending one would kill the other.

noircash binds the leaf index into the nullifier, nf = H(2, nk, cm, index). Two identical commitments at different leaves have different nullifiers, and both can be spent.

The circuit's index is the leaf index inside its tree (below 2^24), not the absolute position. A note that a sender deliberately re-creates with the same stub and shares at the same index of a later tree would share a nullifier with the first one. The wallet computes each note's nullifier and checks it against NullifierSpent events, so it shows such a note as spent once either copy is spent.

The locked seed note

At deployment, the deployer buys NOIR on the curve (SEED_ETH, default 0.001 ETH) and shields it with stub = keccak256("noircash/locked-seed") mod p and an empty ciphertext. Nobody knows a preimage (opk, rho, r) of that stub, so the note can never be spent. Its role is explained in The vault.

On this page