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
| Field | Type | Meaning |
|---|---|---|
opk | field element | Owner public key, from the registry: H(nk, x_hi, x_lo, y_hi, y_lo). See Keys |
rho | field element | Random value chosen by the note's creator |
r | field element | Random blinding value |
shares | at most 120 bits | Vault 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 nodeThe 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, randomnk,rhoandr, index 0. The circuit skips the membership check for a zero-value input, but still requires its nullifier to beH(2, nk, cm, 0). With a randomnkthe 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| Part | Purpose |
|---|---|
| Ephemeral public key | Fresh 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, shares | Everything 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.
Public NOIR and shielding
NOIR outside the vault is an ordinary ERC-20, how shielding and unshielding move it in and out of notes, and what stays visible on-chain.
Keys
How the privacy keys are derived from one wallet signature, why every spend needs a fresh wallet signature, and how keys are registered.