Introduction
On March 25th, 2025, zkSecurity was tasked to audit the Aleph Zero Shielder project. The specific code to review was shared via a public repository (https://github.com/Cardinal-Cryptography/zkOS-monorepo at commit 22e2f52) and a private repository (zkOS-circuits at commit 491a0dd). The audit lasted 2 weeks with 2 consultants.
The code was found to be clean and well tested. A few observations and findings have been reported to the Aleph Zero team, which are detailed in the following sections.
Scope
The scope of the audit included the following components:
The Shielder protocol circuits. This part consisted of the circuits for account creation, deposit, withdrawal and Merkle proof verification. This logic was located in zkOS-circuits/crates/shielder-circuits/src.
Solidity smart contracts. This part included the contract handling business logic (in zkOS-monorepo/contracts) and the script to generate solidity assembly code for the Poseidon2 hash (in zkOS-monorepo/poseidon2-solidity).
Client SDK written with Rust and TypeScript. This part included the Rust crates for the typescript client (zkOS-monorepo/crates/shielder_bindings, zkOS-monorepo/crates/shielder-account, and zkOS-monorepo/crates/type-conversions) and the typescript client packages (zkOS-monorepo/ts/shielder-sdk/src, zkOS-monorepo/ts/shielder-sdk-crypto, and zkOS-monorepo/ts/shielder-sdk-crypto-wasm).
The Halo2 verifier in the contract and the Poseidon2 component in the circuit are not within the scope of this audit.
Overview
Overview of the Shielder Protocol
The Shielder is a zcash-like shielded pool run on EVM chains. It allows users to deposit and withdraw their tokens (including ERC20 token and Native token) with privacy. The relation among these deposits and withdrawals is hidden by default. In order to protect the Shielder from bad actors, the protocol introduces the Anonymity Revoker that can reveal the activity of an account when necessary (e.g., when a hacker is depositing illicit funds).
At a high level, the Shielder is a smart contract that holds all the users’ deposited funds. The ownership of these funds is not publicly revealed. Instead, the contract accepts deposits and withdrawals by verifying Zero Knowledge Proofs in Halo2. From the user’s perspective, they can create their own accounts in the Shielder, deposit, and withdraw freely from the accounts by generating the proof. For every deposit/withdrawal transaction, others won’t learn which account it’s interacting with.
Note
The core data structure in Shielder is the Note, representing the state of an account. It’s held by the account owner and not publicly revealed.
#[derive(Copy, Clone, Debug)]
pub struct Note<T> {
pub version: NoteVersion,
pub id: T,
pub nullifier: T,
pub trapdoor: T,
pub account_balance: T,
pub token_address: T,
}
When a user deposits or withdraws a token in the protocol, it will consume the old Note (old account state) and create a new Note (new account state). For example, when a user deposits a 100 unit of token into the account, the circuit will add 100 to the account_balance and thus create a new Note. The circuit ensures that the transition is valid.
Let’s go through each field one by one:
versionrepresents the protocol version, currently always0.idis the unique identifier of an account. When a user deposits/withdraws, theidbetween the oldNoteand newNotesare always the same (but still private). Theidhere helps to track the transitions of an Account when necessary (more on this later).nullifieris used to invalidate the consumedNote. During theNotetransition, the circuit will calculatehash_old_nullifier = hash(old_nullifier)and make thehash_old_nullifierpublic. The smart contract will add thehash_old_nullifierto a global set and ensure there is no duplicatehash_old_nullifier. As thehash_old_nullifieris deterministically derived fromold_nullifier, this ensures that aNotecan only be spent once. The user always needs to set a new nullifier for newNote. This is a common pattern used in the shielded pool like Zcash and Tornado Cash.trapdooracts like a “private key”. Only the user who holds thetrapdoorcan spend theNote. This is because the user can only create a valid proof with thetrapdoorvalue (as well as other fields in theNote). During theNotetransition, the user can either set a newtrapdooror keep the sametrapdoorin theNote.token_addressis the token address that this account holds. One account can only hold one type of token, which is either native token (e.g., ETH on Ethereum Mainnet) or ERC20 token. The token address will stay the same duringNotetransition.account_balanceis the balance of the account. During deposit/withdrawal transition, the circuit will constrainold_account_balance + delta_amount = new_account_balance, wheredelta_amountis the public deposit/withdrawal amount.

During the Note transition, the protocol needs to ensure the user is spending a valid Note. This is achieved by checking (a.) the Note exists in the system and (b.) it hasn’t been consumed yet. The latter is enforced by the nullifier described above. The former is achieved using a Merkle tree proof.
Every time a new Note is created (either via account creation or deposit/withdrawal), the circuit calculates the new note hash note_hash = hash(note) and emits it. The contract will insert the note_hash to a global note hash Merkle tree. During a deposit/withdrawal transaction, the circuit enforces the spending note hash exists in the Merkle tree. This ensures that the spending Note must exist in the system (i.e., must be created by a prior transaction). As the Merkle check is in the circuit, others won’t learn the exact note hash value or its position in the Merkle tree. Thus the others won’t know which exact Note it is spending. That is, “We know the transition is valid but We don’t know which account it is handling”.
Anonymity Revoker
An innovative part of the Shielder is to introduce Anonymity Revoker to track the account when necessary.
By design, in the Note transition, the id is never changed. The idea here is to enable the Anonymity Revoker to “decrypt” the id in each transition. Here is how to do that:
- Each account has a viewing key derived from
id:viewing_key = hash(id, "key for AR"). As theidof an account is never changed, so is theviewing_key. When the account is created, theviewing_keyis encrypted using Anonymity Revoker’s public key and the ciphertext is emitted. Then the Anonymity Revoker can decrypt the ciphertext to get theviewing_key. As a result, the Anonymity Revoker is able to collect theviewing_keyof all accounts. - During each deposit/withdrawal transition, the account will emit a
macthat commits theviewing_keyof the account. Themacis computed as(r, hash(r, viewing_key)). The first partris a random value, the second part is the hash of the random valuerandviewing_key. Although this doesn’t directly reveal theviewing_key, it enables us to verify if the mac belongs to a given viewing key. For example, given amacthat has value(m1, m2), we want to check if it is emitted by the viewing keyvk. We just need to check ifm1 == hash(m2, vk). If yes then the transition is associated with thevk. That means, given avk, we can scan all the transitions in history to match the transitions that belong to thevk. In that way we can reveal all the transitions of avk. - If the Anonymity Revoker finds a malicious transition from a bad actor (for example, a hacker deposited the illicit fund into an account), it first reveals the
viewing_keyof the transition. They do this by trying every viewing key (collected in step 1) to match themacof the transition. Once found theviewing_key, it just makes it public. Then everyone else is able to verify that theviewing_keyis correct and use it to enumerate every transition to extract these transitions that belong to theviewing_key. This approach makes all the transactions of the bad actor become linkable.
Some notes for the protocol:
- The Anonymity Revoker has to decrypt all
viewing_keyin account creation transactions to reveal one bad account. To ease this burden, one possible way is to encrypt theviewing_keyin every transaction (including deposit/withdrawal). In this way the Revoker will only need to decrypt one singleviewing_keyto track the bad account. However, this will introduce a larger circuit in deposit/withdrawal and lead to a longer proving time. - Currently the revoker key is never changed. The key can decrypt all the transactions in history. Another possible way is to rotate the revoker key—say, once a month—and destroy the old key one month after it is revoked. If a transaction is found to be bad within one month, the Revoker just decrypts the transaction with the key. After one month the Revoker destroys the key and ensures all the previous transactions are not decryptable anymore. This achieves better privacy for the users because their transactions will eventually become fully private. This will also simplify the revoker key management because the Revoker doesn’t need to backup one key forever. It can even just generate the key in a secure enclave and make sure the key never leaves the hardware. Note that, to ensure the Revoker can decrypt an account with a new key, we still need to encrypt the
viewing_keyin every transaction. - One might think that during the
Notetransition, we can directly emitold_nullifierinstead of calculating the hash. Unfortunately, this will lead to account locking issues. In that case theold_nullifierwill be publicly included in the transaction. Others can frontrun the user’s transaction to set the sameold_nullifierin theNoteand consume it. Then theold_nullifierwill be inserted to the global set and the user won’t be able to consume the originalNoteanymore. - The random
rof themacis freely chosen by the user. If two transitions emit twomacthat have the samerbut different commitment, then we know that they have different viewing keys and thus are from different accounts.
Shielder Circuits
The Shielder system under review consists of three main circuits new_account, deposit, and withdraw. They are implemented on top of a custom framework based on a PSE fork of Halo2. These circuits rely on several building blocks:
- ElGamalEncryptionChip: Handles ElGamal encryption for the viewing key, requiring scalar multiplication and point addition on the Grumpkin curve.
- RangeCheckChip: Ensures a balance does not underflow or overflow its intended range.
- MerkleCircuit: Verifies Merkle inclusion proofs for notes.
- NoteChip: Generates note hashes, enforces balance constraints, and checks the note version.
- MacChip: Ensures the mac commitment is derived from the viewing key.
- PoseidonCircuitHash: Calculates the hash in circuit, which is outside the scope of this audit.
Custom Framework
The custom framework atop Halo2 aims to simplify circuit composing by providing a structured way to defined gates:
-
Gate Trait. Each custom gate implements a
Gatetrait specifying the gate’s inputs, advice columns, and constraints, and how the gate should be synthesized with the inputs. -
Column Pool. A shared
ColumnPoolautomatically manages advice columns. Instead of manually declaring columns, the framework picks the least-used columns during synthesis to distribute assignments evenly and avoid “hot spots”, aiming to reduce circuit size. The pool tracks column usage and must remain stateful betweenconfigureandsynthesizephases. Hence, it uses phase flags (ConfigPhase,PreSynthesisPhase,SynthesisPhase) to safeguard the needs of cloning when passing the pool between Halo2’s configuration and synthesis steps.
Scalar Multiplication
The Elliptic Curve scalar multiplication is needed for the ElGamal encryption. The circuit encodes the scalar value as a bit vector of fixed size. For each scalar bit, it applies constraints to ensure the correctness of the scalar multiplication process.
Scalar Bits Representation: A scalar is represented by bits , where is the bit length. Each bit satisfies , ensuring .
Double-and-Add Structure:In each step , two projective points are present in the circuit:
We define:
Conditional Add: Accumulate the input to the result if the scalar bit is non-zero.
Equivalently:
- If <math xmlns="http://www.w3.org/1998/Math/MathML" display="inline"><mrow><msub><mi>b</mi><mi>i</mi></msub><mo>=</mo><mn>0</mn></mrow></math>, <math xmlns="http://www.w3.org/1998/Math/MathML" display="inline"><mrow><msub><mrow><mi mathvariant="bold">r</mi><mi mathvariant="bold">e</mi><mi mathvariant="bold">s</mi><mi mathvariant="bold">u</mi><mi mathvariant="bold">l</mi><mi mathvariant="bold">t</mi></mrow><mrow><mi>i</mi><mo>+</mo><mn>1</mn></mrow></msub><mo>=</mo><msub><mrow><mi mathvariant="bold">r</mi><mi mathvariant="bold">e</mi><mi mathvariant="bold">s</mi><mi mathvariant="bold">u</mi><mi mathvariant="bold">l</mi><mi mathvariant="bold">t</mi></mrow><mi>i</mi></msub></mrow></math>.
- If <math xmlns="http://www.w3.org/1998/Math/MathML" display="inline"><mrow><msub><mi>b</mi><mi>i</mi></msub><mo>=</mo><mn>1</mn></mrow></math>, <math xmlns="http://www.w3.org/1998/Math/MathML" display="inline"><mrow><msub><mrow><mi mathvariant="bold">r</mi><mi mathvariant="bold">e</mi><mi mathvariant="bold">s</mi><mi mathvariant="bold">u</mi><mi mathvariant="bold">l</mi><mi mathvariant="bold">t</mi></mrow><mrow><mi>i</mi><mo>+</mo><mn>1</mn></mrow></msub><mo>=</mo><msub><mrow><mi mathvariant="bold">a</mi><mi mathvariant="bold">d</mi><mi mathvariant="bold">d</mi><mi mathvariant="bold">e</mi><mi mathvariant="bold">d</mi></mrow><mi>i</mi></msub></mrow></math>.
The reason why not to constrain instead
is because the elliptic curve arithmetic constraints are only enforced to the “inner” expressions and , while the “outer” constraints, as shown in the conditional add above, cancels out the to enforce the value of , if the scalar bit is 1.
Double: Doubles the input for the next step.
Enforcing EC Arithmetic: The constraint expressions and , uses Algorithms 7 and 9 from [BCMS15] respectively.
ElGamal Encryption
The ElGamal encryption relies on the Grumpkin curve . Given an Anonymity Revoker’s private key , an account’s private key (an independent random value), plaintext (point on curve), and generator :
- Public key:
- Shared key:
- Ciphertext:
The circuit use ScalarMultipleChip to check that involves scalar multiplications, and use PointsAddChip to check that involves point additions.
Note that a message can map to two points or , but only the -coordinate matters for the viewing key.
Merkle Inclusion Proof
The Merkle tree has arity 7 and 14 levels (including the root). Proving a leaf’s inclusion requires verifying membership at each level up to the claimed Merkle root.
Membership Gate
A single “needle” value must appear in a “haystack” set of size . The product
is zero if and only if is in the set.
Synthesis in a Merkle Chip:
-
Merkle Path Verification. Each level’s nodes(including the current node and sibling nodes) form the haystack, and the current node is the needle.
-
Membership Gate. Enforced the current node is in the sibling set.
-
Next Root. A hash of the siblings and the current node yields the next-level root.
-
Final Constraint. The top-level root is constrained to match the public Merkle root.
Range Check
The range check ensures a given value is less than . It is used to cap note balances (for example, 14 chunks of 8 bits each for a max of ). If a value overflows this limit, it will fail to generate a valid proof.
Gate Constraint
For a single advice column and a lookup table , the gate enforces:
Here, is the value to range check.
Running Sum
To range check a value that is larger than the encoded in the lookup table, the value can be split into smaller parts, forming the running sums of fixed-size chunks. A circuit then can be synthesized to check the running sums of the value with range check for each chunk. The running sum constraints can be constructed as follows:
-
Chunk Decomposition
The value is split into pieces, each under . -
Running Sum Construction
If , the value overflowed.
Note Hash
Whenever a new note is created, its hash needs to be inserted as a leaf in the Merkle tree on-chain. To submit a Merkle proof for the new note, it needs to prove the knowledge of the note using NoteChip.
A note comprises of the fields:
[
version,
id,
nullifier,
trapdoor,
token_balance,
token_address
]
Before creating a note hash, it first generates a hash for the asset [token_balance, token_address], and then generates the hash using:
[
version,
id,
nullifier,
trapdoor,
h_balance
]
The circuit also enforces a constant constraint on the version field, meaning it only supports a specific note version.
MAC Commitment
The MAC commitment is essentially a signature over the viewing key. It is used by the Anonymity Revoker (AR) to track transactions related to a given viewing key. To prove the MAC commitment is correct, the prover must supply a correct viewing key derived from the id that is already part of the note hash. The circuit checks:
-
Hash(r, key)matches the MAC commitment in the public inputs, whereris the salt value and thekeyis the viewing key. -
rmatches the MAC salt that is in the public inputs.
Circuits
A proof for a new note can be generated through one of the three circuit types: new_account, deposit, and withdraw.
Common to all three circuits are checks on: The nullifier, used to invalidate the old note. The note hash, which is added to the Merkle tree. The MAC commitment, which allows the AR to link transactions if necessary.
New account note
When an account is first created (e.g., during an initial deposit), it must generate a proof for the new note through circuit new_account. Different from deposit and withdraw circuits, it includes ElGamal encryption of the viewing key as an additional public input:
// public inputs
pub enum NewAccountInstance {
HashedNote,
Prenullifier,
InitialDeposit,
TokenAddress,
AnonymityRevokerPublicKeyX,
AnonymityRevokerPublicKeyY,
EncryptedKeyCiphertext1X,
EncryptedKeyCiphertext1Y,
EncryptedKeyCiphertext2X,
EncryptedKeyCiphertext2Y,
MacSalt,
MacCommitment,
}
Here, it checks the EncryptedKeyCiphertext** against the AR’s public key AnonymityRevokerPublicKey*, by applying ElGamal encryption to the viewing key.
Additionally, different from the nullifier in the other two note types, the Prenullifier, which is hash(id), is to prevent an account id from being reused to create multiple new account notes.
Deposit note
A deposit note adds assets to an existing account. It requires that the prover demonstrates the knowledge of the previous note via a Merkle proof using the MerkleCircuit, and validates the updated balance in the new note.
To prove the knowledge of the previous note, it needs to compute the hash of the previous note and its inclusion proof in the Merkle tree with private witness path and public input MerkleRoot. In order to invalidate the previous note on the contract when adding a new note, it checks the nullifier hash of the previous note as a public input.
// private witnesses
pub struct DepositProverKnowledge<T> {
// Old note
pub id: T,
pub nullifier_old: T,
pub trapdoor_old: T,
pub account_old_balance: T,
pub token_address: T,
// Merkle proof
pub path: [[T; ARITY]; NOTE_TREE_HEIGHT],
// New note
pub nullifier_new: T,
pub trapdoor_new: T,
// Salt for MAC.
pub mac_salt: T,
pub deposit_value: T,
}
The public inputs reveal the new note hash HashedNewNote and one of the Merkle roots MerkleRoot for the contract to update the Merkle tree to include the new note. In addition, it also reveals HashedOldNullifier to invalidate the previous note.
// public inputs
pub enum DepositInstance {
MerkleRoot,
HashedOldNullifier,
HashedNewNote,
DepositValue,
TokenAddress,
MacSalt,
MacCommitment,
}
The DepositValue and TokenAddress are for the contract to validate the actual deposited asset value.
Withdraw note
The withdrawal note is to prove the intent to withdraw some of the asset balance from an existing note. It does mostly the same checks as the deposit note, except that it also does the range check on the new balance and withdraw commitment.
For withdrawal, it needs to ensure the previous note has enough balance for the withdrawal amount. Otherwise, the balance value in the new note will be underflowed. Since the maximum values of the balance and the withdrawal amount is , and the field size is around , the underflowed balance will be far more than . So it is sufficient to use the RangeCheckChip to constrain the new balance to be less than to avoid underflow.
During withdrawal on the contract, it needs to specify some additional withdrawal details for the contract to process. To prevent a front-running attack against the proof, the proof attaches a commitment as a public input, which is a hash of these withdrawal details:
// commitment to the withdrawal details
bytes memory commitment = abi.encodePacked(
CONTRACT_VERSION,
addressToUInt256(withdrawalAddress),
addressToUInt256(relayerAddress),
relayerFee,
chainId,
pocketMoney
);
Shielder Smart Contract
The Shielder smart contract holds all funds (including the Native token and ERC20 tokens) and maintains the state of each account. It stores the history of notes as hashes in a Merkle tree for inclusion proof and nullifier hash set to avoid double spending. Below is a typical flow during deposit action:
- The user generates a Halo2 proof for the
Notetransition during the deposit. They calldepositERC20with the proof and other public inputs, such asoldNullifierHash, the new note hash, andmac. - The contract verifies the circuit proof, ensuring the old
Noteexists in the given Merkle root and that theNotetransition is valid. - The contract enforces that
oldNullifierHashis not in the nullifier hash set and inserts it into the set, marking the oldNoteas consumed. - The contract inserts the new
Notehash into the note-hash Merkle tree. - The contract transfers the given amount of tokens from the sender to itself and emits a
Depositevent.
The Merkle tree in Shielder has an arity of 7 and a height of 13, supporting up to leaves. It uses the Poseidon2 hash over the BN254 scalar field. This configuration is mainly to reduce the overhead of the Halo2 circuit.
Shielder SDK
The Shielder SDK on scope consisted of mainly two parts, the Rust crates used for WASM bindings (shielder-bindings, shielder-account and type-conversions)
and the Typescript client packages (Shielder-sdk, Shielder-sdk-crypto and Shielder-sdk-crypto-wasm). Those packages essentially encode the client side logic of the Shielder protocol and have the following main functions:
- To handle the generation of IDs that are used for various chains and tokens
- To implement the logic that maintains the local status of a client (which transactions they have made on which chain)
- To generate proofs for
NewAccount,DepositandWithdrawactions and call the smart contract to commit them on chain
The SDK allows a client to have a local main account which depends on a given private key. By design, all other important intermediate secrets used in the Shielder protocol are derived from this private ID. In particular:
- For every
chain_idand every (consecutive, starting with zero) token indexIndex, anIDis generating using theprivate_keyby hashing the concatenation of these values:
- Given an and a
nonceanullifieris generated as:
- Similarly a
trapdooris generated as:
These derivation functions are defined in crates/shielder-account/src/secrets.rs. This design thus implies the following: A client with tokens on multiple chains will have:
- An ID family for a given chain (
chain_id) - An ID for every token on a given
chain_id, which is indexed starting from . That, is if he has 3 tokens for a givenchain_id, there will be 3 IDs, one for each token, with indexes and . - Every time an action is performed (new account, deposit or withdraw) the nonce is incremented to compute a new
nullifierandtrapdoor.
Therefore by design, for every token, there is a seemingly random ID, and for every action, a corresponding random nullifier and a trapdoor. Those also look seemingly random without known the ID (random seed), which is private to the ZKP circuits. At the same time, from a given key an account can be recovered by recomputing the necessary IDs.
State persistence and synchronization
For local persistence of an account’s state, the SDK implements an abstract interface InjectedStorageInterface that can be instantiated to interact with the browser’s localStorage or some other file system or local database. It is the implementers responsibility to make sure that persistent states are stored securely (i.e. encrypted). State transitions on-chain are monitored by the synchronization logic specified in shielder-sdk/src/state/sync/synchronizer.ts. Here, for each chain ID and token, it is checked whether a given nullifier is marked as spent, by querying the block of the nullifier through the Nullifier.sol smart-contract.