State Transitions
All authoritative protocol state lives in the Orchestrator's storage, in a single ERC-7201-slotted struct managed by CommitmentTreeLib. The Vault holds tokens but keeps no ledger — the tree and nullifier set are the ledger; the Vault is a pure custodian.
The state
struct CommitmentsTree {
uint256 treeNumber; // active tree index
mapping(uint256 => LeanIMTData) merkleTrees; // treeNum → depth-16 LeanIMT
mapping(uint256 => mapping(uint256 => bool)) rootHistory; // treeNum → root → seen
mapping(uint256 => bool) spentNullifiers; // global spent set
}
Three sub-structures, three jobs:
| State | What it is | Why |
|---|---|---|
merkleTrees | A sequence of depth-16 binary Lean Incremental Merkle Trees (Poseidon2 over two field elements). Each holds up to 2^16 = 65,536 leaves. | Every output note's commitment is a leaf. A note is spendable iff it is a member of some tree. |
rootHistory | Per tree, the set of every root the tree has ever had. | A spend proves membership against some past root (its "anchor"). Roots rotate as the bundle window fills; rootHistory tolerates that without a ring buffer. |
spentNullifiers | A global set of consumed nullifiers. | Marks notes as spent. Prevents double-spends. Global (not per-tree) because a nullifier is unique across all notes. |
The tree is append-only: leaves are never removed. Spending a note does not touch the tree — it only adds the note's nullifier to
spentNullifiers. This is what unlinks spends from the notes they consume.
The multi-tree design
When the active tree fills (65,536 leaves), insertLeaves starts a new tree and increments treeNumber. Each tree keeps its own rootHistory. A spend's anchor carries (treeNumber, commitmentRoot), so a note proven against tree k is validated against rootHistory[k] — and the two inputs of one Action may live in different trees (D5).
A single insert batch must fit inside one tree: if count > 2^16 the call reverts (BatchExceedsTreeCapacity) — unreachable under the block gas limit, but a guard against silently overflowing a depth-16 tree to depth 17 and breaking every membership proof.
Deposit: the simple transition
State delta: one new leaf in the active tree, one new entry in that tree's rootHistory. No nullifier changes. No token leaves the Vault.
Transact: checks then effects
transact is a strict Checks-Effects-Interactions pipeline. Steps 1–6 are pure validation; step 7 is the only persistent mutation; step 8 is interaction (token movement and external calls).
Step 3 — anchor + nullifier validation (per enabled input)
The transient (in-batch) dedup uses tstore keyed by keccak256(nf, NF_DEDUP_DOMAIN), namespaced so it can never alias the reentrancy-guard slot. This catches a nullifier repeated within one transact that the persistent set would not yet see. The circuit already enforces nf1 ≠ nf2 inside an Action; this is on-chain defense in depth across Actions.
Step 4 vs Step 6 — two different digests
The contract computes per-action digests once (computeActionDigests), then folds them two different ways:
publicsDigest(step 4): a SHA-256 binary tree over all per-action digests across the whole batch — this is the value bound into thefinalizeproof, so it must match the aggregator's fold byte-for-byte.subPublicsDigest(step 6): a per-bundle keccak over that bundle's slice — the message the bundle's binding signature signs.
Each per-action digest folds in the bundle's authCtx, so the proof (via publicsDigest) transitively enforces that the in-circuit spend-auth signatures were verified against the same context the contract reconstructs.
Step 7 — the only persistent mutation
Up to 2 × ops.length output leaves are inserted in one batch (gated per side). A zero commitment is rejected (ZeroCommitmentLeaf). The insert may roll to a new tree mid-batch if the active one lacks capacity.
Step 8 — settlement and interaction (per bundle)
This is where tokens move and external code runs. It is bracketed by a transient-balance invariant: the pool snapshots its balance for every asset the bundle could touch before settlement, and asserts it returns to that snapshot after. Any drift reverts the whole transact.
The snapshot is what makes refunds safe: only this bundle's net contribution (balanceOf − snapshot) is ever swept into a refund note — a third party's direct donation, or an AppAccount's standing balance, is never minted into someone else's refund. A failed action's input value is re-shielded rather than confiscated.
Invariants summarized
| Invariant | Where enforced |
|---|---|
| A note is spendable ⇔ its commitment is in some tree | circuit membership check + rootHistory anchor lookup |
| A nullifier is consumed at most once | spentNullifiers (persistent) + transient in-batch dedup |
| Tree depth is always exactly 16 | BatchExceedsTreeCapacity guard + roll-on-full |
| State mutates only after all checks pass | CEI ordering in transact |
| Tokens are conserved across a bundle | transient-balance snapshot/assert + binding signature |
nonReentrant across the whole transact | ReentrancyGuardTransient |
For the full storage layout and function reference, see Orchestrator and Commitment Tree.