Ledger ADTs
This note covers the collection types available for on-chain state, when to use each and the tradeoffs.
Docs: Ledger ADT Examples: 09.01 ADTs · 09.02 Merkle Trees · 09.03 Kernel
Intuition First
Ledger ADTs are the data structures you use for on-chain state. They’re not like in-memory data structures in TypeScript, they’re persistent, on-chain, and their operations produce verifiable state transitions.
Each type optimizes for a specific access pattern:
- Counter, Low-contention atomic counters
- Set, Membership tracking
- Map, Key-value storage
- List, Ordered queue
- MerkleTree, Membership proofs with a single root
- HistoricMerkleTree, Membership proofs across time
The choice matters because the on-chain representation affects what your circuits can prove.
Counter
A simple unsigned counter. The key advantage over Cell<Uint<64>> is that Counter does not read the current value when incrementing, so transactions are less likely to be rejected due to contention.
ledger votes: Counter;
export circuit vote(): [] {
votes += 1;
}
export circuit retract(): [] {
votes -= 1;
}
export circuit getVotes(): Uint<64> {
return votes;
}
Operations:
| Operation | Syntax | What it does |
|---|---|---|
| Increment | field += n | Add n, no read required |
| Decrement | field -= n | Subtract n, errors if negative |
| Read | field | Returns as Uint<64> |
| Compare | field.lessThan(n) | Returns Boolean |
Why low contention? A normal Uint<64> read-modify-write has three steps: read the value, add one, write back. Under concurrent calls, two transactions might read the same value and both write the same new value. Counter combines these steps atomically.
Cell (Plain Types)
When you declare ledger f: T without a collection type, it’s implicitly a Cell<T>. Read and write operations use shorthand.
ledger owner: Bytes<32>;
export circuit setOwner(addr: Bytes<32>): [] {
owner = disclose(addr); // shorthand for owner.write(...)
}
export circuit getOwner(): Bytes<32> {
return owner; // shorthand for owner.read()
}
Cell<T> is the default. Use it for single, mutable values.
Set
An unbounded set of unique values. Inserting a duplicate is a no-op.
ledger allowlist: Set<Bytes<32>>;
export circuit addAddress(addr: Bytes<32>): [] {
allowlist.insert(disclose(addr));
}
export circuit isAllowed(addr: Bytes<32>): Boolean {
return allowlist.member(addr);
}
Operations:
| Operation | Syntax | What it does |
|---|---|---|
| Insert | set.insert(v) | Add value, duplicate is no-op |
| Remove | set.remove(v) | Remove value |
| Check | set.member(v) | Returns Boolean |
| Empty check | set.isEmpty() | Returns Boolean |
| Size | set.size() | Returns Uint<64> |
| Reset | set.resetToDefault() | Clears the set |
Useful for allowlists, denylists, and membership tracking.
Map
An unbounded key-value mapping. Values can be other ledger-state types (except Kernel).
ledger balances: Map<Bytes<32>, Uint<64>>;
export circuit setBalance(addr: Bytes<32>, amount: Uint<64>): [] {
balances.insert(disclose(addr), disclose(amount));
}
export circuit getBalance(addr: Bytes<32>): Uint<64> {
return balances.lookup(addr);
}
Operations:
| Operation | Syntax | What it does |
|---|---|---|
| Insert | map.insert(k, v) | Add or update key-value |
| Lookup | map.lookup(k) | Returns value (errors if missing) |
| Check | map.member(k) | Returns Boolean |
| Remove | map.remove(k) | Delete key-value |
| Empty check | map.isEmpty() | Returns Boolean |
| Size | map.size() | Returns Uint<64> |
| Reset | map.resetToDefault() | Clears the map |
| Default insert | map.insertDefault(k) | Insert with default value |
Nested Maps
ledger fld: Map<Boolean, Map<Field, Counter>>;
export circuit increment(b: Boolean, n: Field, k: Uint<16>): [] {
fld.lookup(b).lookup(n) += disclose(k);
}
Rules for nesting:
- Nested values must be initialized before first use.
- The entire indirection chain must be used in a single expression.
- Only
Mapvalues can contain ledger-state types. Kernelcannot be nested.
List
An unbounded ordered list. Elements are pushed and popped from the front.
ledger queue: List<Field>;
export circuit enqueue(v: Field): [] {
queue.pushFront(disclose(v));
}
export circuit dequeue(): [] {
queue.popFront();
}
export circuit peek(): Field {
return queue.head().value;
}
Operations:
| Operation | Syntax | What it does |
|---|---|---|
| Push | list.pushFront(v) | Add to front |
| Pop | list.popFront() | Remove from front |
| Head | list.head() | Returns Maybe<T> |
| Empty check | list.isEmpty() | Returns Boolean |
| Length | list.length() | Returns Uint<64> |
| Reset | list.resetToDefault() | Clears the list |
head() returns Maybe<T>, check .isSome before accessing .value.
MerkleTree
A bounded Merkle tree of depth n (2 ≤ n ≤ 32). Use when you need to prove membership against the current root.
ledger members: MerkleTree<8, Bytes<32>>;
export circuit addMember(leaf: Bytes<32>): [] {
members.insert(disclose(leaf));
}
export circuit checkMembership(root: MerkleTreeDigest): Boolean {
return members.checkRoot(root);
}
Operations:
| Operation | Syntax | What it does |
|---|---|---|
| Insert | merkle.insert(v) | Add leaf, update root |
| Check | merkle.checkRoot(digest) | Verify leaf against current root |
| Full check | merkle.isFull() | Returns Boolean |
| Reset | merkle.resetToDefault() | Clears the tree |
| Root | merkle.root() | Read-only in TypeScript |
| Path | merkle.pathForLeaf(idx) | Get Merkle path by index (TypeScript only) |
| Find path | merkle.findPathForLeaf(v) | Scan for leaf and return path (TypeScript only) |
What this enables: A compact proof that a value is a member of the set, without revealing the value or the full set. The proof is a Merkle path, a sequence of sibling hashes from the leaf to the root.
Use case: Prove you know a secret in a set without revealing the secret.
HistoricMerkleTree
Like MerkleTree but retains past roots. Use when you need to prove membership against any past root.
ledger commitments: HistoricMerkleTree<16, Bytes<32>>;
export circuit addCommitment(leaf: Bytes<32>): [] {
commitments.insert(disclose(leaf));
}
export circuit verifyOldMembership(root: MerkleTreeDigest): Boolean {
return commitments.checkRoot(root); // accepts any past root
}
What this enables: Proving that a commitment existed at a past point in time. Useful for nullifier-based systems where you need to prevent double-spending.
Additional operations beyond MerkleTree:
| Operation | What it does |
|---|---|
tree.resetHistory() | Clear past roots |
tree.history() | Iterator over past roots (TypeScript only) |
Kernel
A special ledger-state type that provides access to built-in ledger operations.
ledger kern: Kernel;
export circuit checkTime(deadline: Uint<64>): Boolean {
return kern.blockTimeLessThan(deadline);
}
export circuit selfAddress(): ContractAddress {
return kern.self();
}
Operations:
| Operation | What it does |
|---|---|
kern.self() | Returns this contract’s address |
kern.balance() | Native token balance |
kern.balanceGreaterThan(amount) | Check balance exceeds threshold |
kern.balanceLessThan(amount) | Check balance below threshold |
kern.blockTimeGreaterThan(time) | Check block time exceeds value |
kern.blockTimeLessThan(time) | Check block time below value |
kern.checkpoint() | Create an atomic execution boundary for upgrades |
kern.claimContractCall(addr) | Verify a cross-contract call |
kern.claimUnshieldedCoinSpend(...) | Claim an unshielded coin spend |
kern.claimZswapCoinReceive(...) | Claim a Zswap coin receipt |
kern.claimZswapCoinSpend(...) | Claim a Zswap coin spend |
kern.claimZswapNullifier(...) | Claim a Zswap nullifier |
kern.incUnshieldedInputs(n) | Increment unshielded input counter |
kern.incUnshieldedOutputs(n) | Increment unshielded output counter |
kern.mintShielded(...) | Mint shielded tokens |
kern.mintUnshielded(...) | Mint unshielded tokens |
Kernel cannot be nested in a Map.
Coin Operations on ADTs
Several ADTs support direct coin insertion for shielded token management:
| ADT | Operation | What it does |
|---|---|---|
Cell<T> | cell.writeCoin(coin, recipient) | Write a QualifiedShieldedCoinInfo directly |
Set<T> | set.insertCoin(coin, recipient) | Insert a shielded coin into the set |
Map<K, V> | map.insertCoin(k, coin, recipient) | Insert a shielded coin as a map value |
List<T> | list.pushFrontCoin(coin, recipient) | Push a shielded coin to the front |
Coin operations bypass the normal value decomposition. Use them when managing shielded tokens without decomposing them into raw values.
Choosing the Right ADT
| Use case | ADT | Why |
|---|---|---|
| Single mutable value | Cell<T> | Simple, direct access |
| Atomic counter (low contention) | Counter | Increment without read |
| Membership tracking | Set<T> | O(1) insert, check, remove |
| Per-key storage | Map<K, V> | Key-value with O(1) lookup |
| Nested per-key storage | Map<K, Map<K2, V>> | Two-level lookup |
| Ordered queue | List<T> | Push/pop from front |
| Current membership proof | MerkleTree<n, T> | Single-root membership |
| Historical membership proof | HistoricMerkleTree<n, T> | Multi-root membership |
| Block time, tokens, address | Kernel | Built-in operations |
Common Mistakes
-
Using
MapwhenSetsuffices. If you only need membership check, useSet.Maphas more overhead for the value storage. -
Forgetting that
Listpushes from front.pushFrontadds to the front,popFrontremoves from the front. This is a stack, not a queue, the name reflects the storage order, not the access pattern. -
Not initializing nested values. Before accessing
map.lookup(k).lookup(k2), the innerMapforkmust exist. The compiler requires the entire chain in one expression. -
Using
MerkleTreewhen you need historical proofs.MerkleTreeonly proves against the current root. If you need past roots, useHistoricMerkleTree. -
Nesting
KernelinMap. Not allowed.Kernelis a special type with built-in operations, it cannot be nested.
Comparison Layer
| ADT | Solidity equivalent | Note |
|---|---|---|
Cell<T> | uint256 | Simple storage slot |
Counter | N/A | Atomic increment |
Set<T> | mapping → bool | Unique membership |
Map<K, V> | mapping(K ⇒ V) | Key-value |
List<T> | uint256[] | Ordered with overhead |
MerkleTree | N/A | ZK-native, not Solidity-native |
Kernel | built-in | Block time, tokens |
Quick Recap
- Counter, Low contention, atomic increment without read.
- Cell, Default for single values. Read/write shorthand.
- Set, Membership tracking, unique values.
- Map, Key-value with nested ledger types.
- List, Ordered stack, push/pop from front.
- MerkleTree, Current-root membership proofs.
- HistoricMerkleTree, Any-past-root membership proofs.
- Kernel, Built-in operations (time, tokens, address). Cannot be nested.
- Choose based on access patterns. The ADT affects what your circuits can prove.
Cross-Links
- Previous: Data Types Type system
- Next: Standard Library Hashing and token functions
- See also: Ledger State Commitment patterns
- See also: Standard Library Merkle tree functions
- Examples: 09.01 ADTs · 09.02 Merkle Trees · 09.03 Kernel