Commitments & nullifiers
The two hashes that make shielded value work — one to create a note, one to spend it exactly once.
Two Poseidon hashes carry the whole spend model. A commitment registers a note without revealing it. A nullifier spends that note exactly once, without revealing which note it was. The asymmetry between them is what keeps Hestia both unforgeable and unlinkable.
Commitments
A commitment is a binding, hiding hash of a note's five fields:
commitment = poseidon([value, token, owner, label, randomness])- Binding — you cannot find a different note that produces the same commitment, so a leaf in the tree pins down exactly one note.
- Hiding — because
randomnessis a fresh field element, the commitment reveals nothing aboutvalue,token, orowner.
The field order is canonical and frozen (COMMITMENT_ARITY = 5). The circuit recomputes this
hash inside the proof and the contract recomputes it on-chain, so all three — JS, circuit,
and Solidity — agree to the bit. Computing one:
import { commitment, newNote } from "@hestia/common";
const c = await commitment(newNote({ value: 5n, token: 0n, owner: SK, label }));When you shield or receive, the commitment becomes a new leaf in the commitment tree.
Nullifiers
To spend a note you publish its nullifier:
nullifier = poseidon([commitment, leafIndex, sk])Three properties make this work (NULLIFIER_ARITY = 3):
- It is deterministic — the same note always yields the same nullifier — so the pool can reject a second spend by checking a set membership.
- It is unlinkable — it depends on the secret
sk, so no observer can connect a published nullifier back to a specific commitment or owner. - It is unforgeable — only the holder of
skcan produce the nullifier for a note whoseownerisSK = poseidon([sk]), and the circuit proves that relationship.
Binding leafIndex into the nullifier ties the spend to the note's exact position in the
tree, closing replay paths across re-inserted commitments.
The spend, end to end
create: note ──poseidon(5)──► commitment ──► inserted as leaf #i
spend: (commitment, i, sk) ──poseidon(3)──► nullifier ──► recorded as "spent"When a transaction is verified on-chain, the pool:
- checks the proof's Merkle root is a recent, known tree root;
- checks each input nullifier is not already in the spent set, then inserts it;
- appends the two output commitments as new leaves; and
- stores the output ciphertexts for the recipients to discover.
Because step 2 reveals only nullifiers (not commitments) and step 3 adds fresh commitments with new randomness, an observer sees notes destroyed and notes created — but never which new note came from which old one.
Double-spend protection
A note can be nullified once. The second attempt produces the same nullifier, the contract finds it already in the set, and the transaction reverts. The SDK also tracks the local nullifier set so it never tries to spend a note it already spent — see coin selection.
