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:
version
represents the protocol version, currently always0
.id
is the unique identifier of an account. When a user deposits/withdraws, theid
between the oldNote
and newNotes
are always the same (but still private). Theid
here helps to track the transitions of an Account when necessary (more on this later).nullifier
is used to invalidate the consumedNote
. During theNote
transition, the circuit will calculatehash_old_nullifier = hash(old_nullifier)
and make thehash_old_nullifier
public. The smart contract will add thehash_old_nullifier
to a global set and ensure there is no duplicatehash_old_nullifier
. As thehash_old_nullifier
is deterministically derived fromold_nullifier
, this ensures that aNote
can 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.trapdoor
acts like a “private key”. Only the user who holds thetrapdoor
can spend theNote
. This is because the user can only create a valid proof with thetrapdoor
value (as well as other fields in theNote
). During theNote
transition, the user can either set a newtrapdoor
or keep the sametrapdoor
in theNote
.token_address
is 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 duringNote
transition.account_balance
is the balance of the account. During deposit/withdrawal transition, the circuit will constrainold_account_balance + delta_amount = new_account_balance
, wheredelta_amount
is 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 theid
of an account is never changed, so is theviewing_key
. When the account is created, theviewing_key
is 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_key
of all accounts. - During each deposit/withdrawal transition, the account will emit a
mac
that commits theviewing_key
of the account. Themac
is computed as(r, hash(r, viewing_key))
. The first partr
is a random value, the second part is the hash of the random valuer
andviewing_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 amac
that 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_key
of the transition. They do this by trying every viewing key (collected in step 1) to match themac
of the transition. Once found theviewing_key
, it just makes it public. Then everyone else is able to verify that theviewing_key
is 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_key
in account creation transactions to reveal one bad account. To ease this burden, one possible way is to encrypt theviewing_key
in every transaction (including deposit/withdrawal). In this way the Revoker will only need to decrypt one singleviewing_key
to 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_key
in every transaction. - One might think that during the
Note
transition, we can directly emitold_nullifier
instead of calculating the hash. Unfortunately, this will lead to account locking issues. In that case theold_nullifier
will be publicly included in the transaction. Others can frontrun the user’s transaction to set the sameold_nullifier
in theNote
and consume it. Then theold_nullifier
will be inserted to the global set and the user won’t be able to consume the originalNote
anymore. - The random
r
of themac
is freely chosen by the user. If two transitions emit twomac
that have the samer
but 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
Gate
trait specifying the gate’s inputs, advice columns, and constraints, and how the gate should be synthesized with the inputs. -
Column Pool. A shared
ColumnPool
automatically 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 betweenconfigure
andsynthesize
phases. 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 , .
- If , .
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, wherer
is the salt value and thekey
is the viewing key. -
r
matches 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
Note
transition during the deposit. They calldepositERC20
with the proof and other public inputs, such asoldNullifierHash
, the new note hash, andmac
. - The contract verifies the circuit proof, ensuring the old
Note
exists in the given Merkle root and that theNote
transition is valid. - The contract enforces that
oldNullifierHash
is not in the nullifier hash set and inserts it into the set, marking the oldNote
as consumed. - The contract inserts the new
Note
hash into the note-hash Merkle tree. - The contract transfers the given amount of tokens from the sender to itself and emits a
Deposit
event.
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
,Deposit
andWithdraw
actions 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_id
and every (consecutive, starting with zero) token indexIndex
, anID
is generating using theprivate_key
by hashing the concatenation of these values:
- Given an and a
nonce
anullifier
is generated as:
- Similarly a
trapdoor
is 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
nullifier
andtrapdoor
.
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.