👁️Protocol Architecture
Note-Based UTXO Model
Unlike Ethereum's account-based model where each address has a single balance, Nonce implements a UTXO (Unspent Transaction Output) model similar to Bitcoin but with encrypted notes. Each note represents an owned amount of a specific token. A user's total balance consists of the sum of all their unspent notes. This model provides natural privacy because spending a note creates entirely new notes with no obvious connection to the originals.
A note in Nonce contains several components. The value field v represents the amount of tokens. The token field t specifies the ERC-20 contract address. The owner's public key pkₒ determines who can spend the note. The random blinding factor ρ ensures commitment uniqueness. Together, these create a note tuple N = (v, t, pkₒ, ρ) with commitment cm = Poseidon(v, t, pkₒ, ρ).
When a user shields tokens into Nonce, the protocol creates a new note with the deposited amount and computes its commitment. This commitment gets inserted into a Merkle tree maintained by the privacy pool contract. The user stores the note details locally and can later prove ownership by showing they know the note preimage for a commitment in the Merkle tree. When spending, the user creates a zero-knowledge proof that demonstrates knowledge of a note in the tree without revealing which note, then creates new output notes for the recipient (and change back to themselves if applicable).
Merkle Tree Structure
The privacy pool maintains all note commitments in an append-only Merkle tree. This data structure allows users to prove that a commitment exists in the tree with logarithmic proof size while maintaining privacy about which commitment they're proving. Nonce uses a sparse Merkle tree with height 32, allowing for 2³² note commitments and providing balance between tree height (affecting proof size) and capacity.
For a tree with leaves ℓ₀, ℓ₁, ..., ℓₙ₋₁, the tree is constructed bottom-up using Poseidon hash. Each internal node is the hash of its two children: hᵢ = Poseidon(h₂ᵢ, h₂ᵢ₊₁). The root hash aggregates all leaf commitments. A Merkle proof for leaf ℓₖ consists of the sibling hashes along the path from leaf to root. Given these siblings (s₀, s₁, ..., s₃₁), anyone can recompute the root and verify the leaf exists in the tree.
The zero-knowledge circuit for verifying Merkle membership takes as private inputs the note N, blinding factor ρ, and path siblings. It computes cm = Poseidon(N.v, N.t, N.pkₒ, ρ) and reconstructs the root hash by iteratively hashing with siblings along the path. The circuit outputs the computed root, which must match the public root stored in the contract. This proves that the prover knows a note in the tree without revealing which position in the tree.
Transaction Structure
A private transaction in Nonce consumes input notes and creates output notes while proving the operation's validity in zero-knowledge. The general transaction structure includes input note indices (positions in the Merkle tree), output note commitments, public inputs or outputs when interfacing with external contracts, and a zero-knowledge proof demonstrating transaction validity.
For a simple transfer, consider a transaction that consumes two input notes with values v₁ and v₂ and creates two output notes with values v'₁ and v'₂. The zero-knowledge proof must establish several facts. First, the prover knows the preimages for two commitments in the Merkle tree by providing note details and Merkle paths. Second, the prover owns these notes by proving knowledge of the private key corresponding to each note's public key. Third, the values balance such that v₁ + v₂ = v'₁ + v'₂, preventing inflation. Fourth, the tokens are the same type across all inputs and outputs. Fifth, the nullifiers for the input notes are correctly computed to prevent double-spending.
The circuit computes nullifiers as nfᵢ = Poseidon(cmᵢ, skₒ) for each input note and outputs them publicly. The smart contract checks these nullifiers haven't been used before, marks them as spent, and adds the new output commitments to the Merkle tree. Observers see only the nullifiers and new commitments with no information about amounts, senders, or recipients.
Privacy Pool Smart Contracts
The core protocol consists of several interconnected smart contracts deployed on Base. The primary PrivacyPool contract maintains the Merkle tree of note commitments and the registry of spent nullifiers. It exposes functions for shielding (depositing public tokens to create private notes), unshielding (spending private notes to withdraw public tokens), and private transfers (spending private notes to create new private notes).
Each operation type has a corresponding verification contract that validates zero-knowledge proofs. The TransferVerifier validates proofs for private transfers, the SwapVerifier validates swap operations, and the YieldVerifier validates deposits and withdrawals from lending protocols. These verifiers implement the pairing check for Groth16 proofs, taking a proof π and public inputs x and returning a boolean indicating validity.
The contract enforces several critical invariants. The Merkle tree is append-only, ensuring commitments can never be removed or altered. The nullifier registry is permanent, preventing double-spends even if a note's commitment is somehow removed. Proof verification must succeed before any state changes occur, preventing invalid transactions. Token accounting is strictly enforced, with the contract balance always matching the sum of values in active notes for each token type.
Shielding and Unshielding Operations
Shielding converts public tokens into private notes. A user approves the PrivacyPool contract to spend their tokens, then calls the shield function with the token address, amount, and a commitment to the note they want to create. The contract transfers the tokens from the user's public address to itself, computes the commitment using the provided parameters, and inserts the commitment into the Merkle tree. The user locally stores the note details (value, token, their public key, blinding factor) needed to later spend this note.
The shielding operation is intentionally not private because tokens must come from somewhere observable on-chain. Observers can see that a specific address deposited a certain amount into the privacy pool. However, once shielded, all subsequent operations with these tokens happen privately. The connection between the depositor's public address and their private notes is broken as soon as other users shield tokens and the anonymity set grows.
Unshielding reverses this process, converting private notes back to public tokens. The user creates a zero-knowledge proof demonstrating ownership of one or more notes whose total value equals the withdrawal amount. The proof reveals the nullifiers for the spent notes and the recipient address for the public tokens. The contract verifies the proof, checks the nullifiers haven't been used, marks them as spent, and transfers the tokens to the specified recipient address. Critically, the recipient address can differ from the original depositor, and observers cannot determine the connection between notes consumed and the withdrawal.
Last updated
