V1 Mainnet · The note model
The formulas
Menoid keeps no balances. Value lives as notes — like banknotes: each has an owner, a token and an amount, and it is spent whole. The chain never sees a note itself, only numbers derived from it. Here is every formula the protocol uses, what it is for, and a small example of each.
Every Poseidon hash starts with a small number, a domain separator from 1 to 5. It guarantees no hash can ever be mistaken for another kind, even with the same inputs.
| Tag | Hash | Gives you |
|---|---|---|
| 1 | Poseidon(1, address, spendPk.x, spendPk.y) | userCommitment — your identity |
| 2 | Poseidon(2, sk) | nk — the nullifier key |
| 3 | Poseidon(3, userCommitment, r) | npk — a note’s owner, blinded |
| 4 | Poseidon(4, token, amount, npk) | commitment — the note on-chain |
| 5 | Poseidon(5, nk, commitment, r) | nullifier — the note’s spent marker |
ucuserCommitment — your private identity
userCommitment = Poseidon(1, address, spendPk.x, spendPk.y)
- What it is
- One number that stands for you inside the pool. It contains your public address and your spending public key.
- Why
- Because it contains your address, people can pay you at your normal address: the pool maps address → userCommitment. Because it contains spendPk, only the holder of sk can prove it is theirs.
- Used in
- Registration (stored on-chain), every note you receive (inside npk), and every proof you make — the circuit rebuilds it from sk and your address, and the proof fails if it doesn’t match.
Example
Bob pays Alice. His app reads registrationOf(Alice’s address) and gets uc_A, then locks a note to it. Knowing Alice’s address and uc_A is no help in spending that note: that needs Alice’s sk.
nkThe nullifier key
nk = Poseidon(2, sk)
- What it is
- A one-way hash of your spending key.
- Why
- Nullifiers are computed from nk instead of sk. Poseidon can’t be run backwards, so sharing nk shows which of your notes are spent without giving away sk.
- Used in
- Every nullifier you publish, and your full viewing key.
Example
An auditor holding Alice’s full viewing key computes the nullifier of each of her notes and sees which were spent, and when. They still can’t make a proof — that needs sk.
npkThe note public key — a note’s owner, blinded
r = 31 random bytes // fresh for every note
npk = Poseidon(3, userCommitment, r)- What it is
- The owner of one note, hidden behind that note’s own random number r.
- Why
- Without r, every note to Alice would carry the same owner value and be trivially linkable. With r, two notes to the same person look unrelated.
- Used in
- Every commitment — and swaps, where the owner of the bought note (npkOut) is fixed before its amount exists.
Example
Alice gets two payments: Poseidon(3, uc_A, r₁) and Poseidon(3, uc_A, r₂). Two unrelated-looking numbers; nobody can tell they both belong to Alice.
cmcommitment — the note, as the chain stores it
commitment = Poseidon(4, token, amount, npk)
- What it is
- A note’s public fingerprint: one leaf of the pool’s Merkle tree. It hides the owner, the token and the amount.
- Why
- The token sits inside the hash, so one pool and one tree hold every token — and a private send doesn’t even reveal which token moved.
- Used in
- Created by every mask, send, swap and unmask; proved to exist (with a Merkle path) when the note is spent.
Example
Mask 5 MON: the pool wraps it to WMON and computes
Poseidon(4, WMON, 5·10¹⁸, npk) itself, from the tokens it actually received. The 5 can’t be faked — the amount in the note is the amount that arrived.nfnullifier — the note’s spent marker
nullifier = Poseidon(5, nk, commitment, r)
- What it is
- A number published when — and only when — the note is spent.
- Why
- The pool remembers every nullifier and refuses one it has seen, so no note is spent twice. Only the owner (nk and r) can compute it, and nobody can link it back to its commitment — spending doesn’t reveal which note was spent.
- Used in
- Every send, swap and unmask publishes exactly 4 nullifiers (empty slots get dummies).
Example
Alice spends her 5 MON note and the proof publishes nullifier N. Anyone submitting N again gets “Nullifier already spent” — and nothing moves.
rootMerkle root — proof that a note exists
leaf = commitment // 0 for an empty leaf node = Poseidon(left, right) root = the top of a depth-20 tree // 1,048,576 leaves; a full tree opens a new one // every operation inserts one chunk of 4 leaves; the pool keeps the last 100 roots
- What it is
- One number that summarises every note in a tree.
- Why
- A proof shows “my note is one of the leaves under this root” without saying which leaf — that is what hides the spender among everyone. Keeping the last 100 roots means a proof made a moment ago still lands after other transactions move the tree.
- Used in
- Every send, swap and unmask proves its input notes against a recent root.
Example
You prove against root R. Before your transaction lands, three other operations change the tree. R is still one of the last 100 roots, so your proof is accepted.
extextDataHash — everything outside the proof, locked to it
domain = keccak256("menoid.v1m.transfer" | "menoid.v1m.withdraw" | "menoid.v1m.swap")
extDataHash = keccak256(abi.encode(chainId, pool, domain, ext)) mod p- What it is
- One public input of the proof that commits to the transaction’s extra data: the encrypted notes and the outgoing record — plus the recipient for an unmask, and the adapter, route, minimum output and deadline for a swap.
- Why
- Change any byte of that data — the recipient, say — and the hash no longer matches the proof, so verification fails. chainId and the pool address stop a proof from being replayed on another chain or pool.
- Used in
- Send, swap and unmask. p is the BN254 field size.
Example
Alice unmasks to 0xB0B…. If a relayer swapped in its own address, extDataHash would change and the pool would reject the proof. A relayer can carry the transaction or drop it — never redirect it.
encencrypted note — how the owner finds a note
plaintext = version ‖ flags ‖ token ‖ amount ‖ r // 1 + 1 + 20 + 16 + 32 = 70 bytes encryptedNote = ECIES(owner’s ivkPub, plaintext) // secp256k1, compressed ephemeral key
- What it is
- The note’s contents, sealed to its owner’s viewing key and emitted in the NoteCreated event.
- Why
- Nothing on-chain says which notes are yours. Your app tries to decrypt every note with your ivk; a success that also rebuilds the commitment means the note is yours.
- Used in
- Every note created. A swap’s bought note carries the flag AMOUNT_FROM_EVENT: its amount is read from the Swapped event, because nobody knew it when the proof was made.
Example
Bob sends Alice 2 USDC. The receiver output is encrypted to Alice’s ivkPub. Her app decrypts it, rebuilds
Poseidon(4, USDC, 2·10⁶, Poseidon(3, uc_A, r)), finds it equals the commitment on-chain, and shows +2 USDC.recoutgoing record — your memo of who you paid
key = HKDF-SHA256(ovk, salt = firstNullifier, info = "menoid/v1m/outgoing-record") record = nonce(12) ‖ AES-256-GCM(key, action ‖ token ‖ amount ‖ fee ‖ counterparty…) ‖ tag(16)
- What it is
- A private record of each spend, readable only with your ovk: the action (send, unmask, swap), token, amount, fee and counterparty.
- Why
- Notes you receive can be found with ivk, but a payment you make leaves you nothing to decrypt. The record restores the other side of your history — who you paid — for you and for anyone holding your full viewing key.
- Used in
- Emitted with every send, swap and unmask as OutgoingRecord(firstNullifier, record).
Example
Alice’s app sees her note’s nullifier N spent, derives the key from her ovk and N, and decrypts: “sent 2 USDC to 0xB0B…, fee 0.01 USDC”.
