# Audit of Zeko's circuits, sequencer and DA layer

- **Date**: June 3rd, 2026
- **Tags**: mina, ocaml, pickles, zkapp, circuits

## Introduction

On February 16th, 2026, zkSecurity started a security audit of Zeko's codebase. The audit lasted two weeks with two consultants. We reviewed the [zeko](https://github.com/zeko-labs/zeko) repository on the `compatible` branch at commit [`007b02e0cc`](https://github.com/zeko-labs/zeko/tree/007b02e0cc).

Overall, we found the code to be of good quality, and the design trade-offs to be well documented and reasonable for the current stage of the project.

This is zkSecurity's second audit of Zeko. The first audit is available here: [first audit](https://reports.zksecurity.xyz/reports/zeko).

### Scope

The audit covered the following components:

- Circuit changes since the previous audit, specifically: the emergency DA rule, the emergency DA folder, changes to the account set insertion logic, Data Availability (DA) threshold signature verification, and changes to the commit and emergency commit rules.
- The sequencer.
- The Data Availability (DA) layer.
- Deployment code for the rollup.

### Threat model and security assumptions

Throughout the audit, we looked at different components of the rollup and considered the potential impact of vulnerabilities in those components under different threat models.
We give a brief overview of the assumptions.

When looking at the rollup circuits, we assume that the sequencer and the prover are malicious and want to produce invalid state transitions or disrupt rollup operations. One important note is that the circuits assume that DA nodes are honest, meaning that DA nodes only sign states for which they have stored the relevant openings. Technically, this renders Zeko a validium, not a proper rollup, because the data availability layer is not strictly enforced by the L1 but relies on external assumptions.

When looking at the DA node code, we assume that these are public services that can be used by anyone, and that the sequencer is malicious and wants signatures from DA nodes without sending all relevant data to them.

Finally, when looking at the sequencer code, we assume that users submitting transactions and making queries to the sequencer are malicious and want to disrupt rollup operations, for example by sending malformed transactions or making queries that cause DoS. We note that the sequencer should be the least security-critical component of the system, as if both circuits and the DA layer work as intended, then the sequencer should be able to recover from malicious user behavior, and the worst-case scenario is a temporary service disruption.

As a final observation, the sequencer is currently able to censor transactions arbitrarily, and that is a documented design trade-off, so we did not consider censorship as a potential attack vector for the sequencer.

### Changes to the zkapp circuits

We begin by describing the main changes to the zkapp circuits since the previous audit, and their security implications.
We refer to the previous audit for a more general description of the circuits and their design.

#### Account set insertion logic

The account set is an incremental Merkle tree (IMT) that tracks the set of accounts that are currently present in the rollup. The account set is used to enforce that no duplicate accounts are created in the ledger. To do this, the insertion logic acts as both a non-inclusion proof for the new account, and an insertion proof for the updated account set root. We refer to the previous audit for a more thorough description of the account set and its insertion logic.

The main change to the account set insertion operation is that the IMT is enforced to be filled from left to right, meaning that leaves cannot be inserted at arbitrary positions in the tree and must be inserted in the order of the transactions being processed. This is done to prevent the sequencer from inserting new accounts in arbitrary positions in the tree, which would not allow users to reconstruct leaf contents from the IMT root in case of an emergency commit.
The implementation of this behavior enforces that a new account leaf is inserted right next to a non-empty leaf, which, together with the assumption that the tree is initialized with at least one leaf on the far left, guarantees that the IMT is filled from left to right.

#### Data Availability for normal commits

When the sequencer produces a normal commit, it must post diff data to the DA layer. We describe the DA layer in more detail in the next section, but for now it suffices to say that the DA layer is composed of nodes that are assumed to be honest and sign target ledger and account-set roots only if they have stored the relevant openings to fully reconstruct those states.
The public keys of all DA nodes are fixed, and a running hash of all keys is stored in the outer state as `da_key`. The commit rule requires DA signatures over the target ledger and account set from at least `quorum` DA nodes, where `quorum` is a configurable parameter.

#### Emergency DA rule and folder

The emergency commit is a special commit operation that can be performed by anyone in case of an emergency, such as a sequencer outage. The main difference from a normal commit is how data availability is handled. Indeed, the emergency commit must also enforce a DA mechanism; otherwise users can permanently lose access to their funds if a malicious user performs an emergency commit and does not publish all relevant ledger openings.

This is addressed by the emergency DA mechanism, which we now describe. The main idea is that the emergency commit also requires users to have stored all the openings required to reconstruct the target ledger state from the source ledger state as actions in a special account, called the emergency DA account.
Actions in this account can be appended by anyone providing a proof for the emergency DA rule. Each action stores the following data:

```ocaml
module Action = struct
  type t =
    { source_ledger_hash : Ledger_hash.t
    ; target_ledger_hash : Ledger_hash.t
    ; ledger_index : Checked32.t
    ; account : Account.t
    }
  [@@deriving snarky]
end
```

The witness of this rule also includes an `old_account` and a Merkle path. The rule enforces that the implied root reconstructed from the `old_account` and the Merkle path matches the `source_ledger_hash`, and that the new root reconstructed from the `account` and the same Merkle path matches the `target_ledger_hash`. In particular, the rule enforces that the new account leaf, stored into `account`, is a correct opening for the new ledger hash. This ensures that users have access to all the data required to perform one ledger "step" from the source ledger to the target ledger.

This process can be extended to a chain of actions: if there exists an action with source `A` and target `B`, and another action with source `B` and target `C`, then users can reconstruct the path from `A` to `C` by following the chain of actions. This is exactly the idea behind the emergency DA folder circuit. This circuit is an instantiation of a "folder" circuit, meaning it is a circuit that recursively builds a claim. The claim has the following shape:

```ocaml
module Stmt = struct
  type t =
    { source_ledger : Ledger_hash.t
    ; target_ledger : Ledger_hash.t
    ; target_action_state : F.t
    }
  [@@deriving snarky]
end
```

The semantics are: "there exists a chain of actions, whose running hash is equal to `target_action_state`, that leads from `source_ledger` to `target_ledger`".
The emergency commit rule requires a proof for the emergency DA folder, with source and target ledger equal to the source and target ledger of the commit, and `target_action_state` equal to the current action state of the emergency DA account. This ensures that users have access to all the data required to reconstruct the path from the source ledger to the target ledger, and thus to reconstruct the target ledger itself.

### DA layer

The DA layer is built as a committee of standalone nodes. Each node exposes an `Async.Rpc` interface and signs a ledger hash only after receiving enough data to reconstruct the corresponding state transition.
In this model, a DA signature is an attestation that the node can reproduce the transition from a source ledger hash to a target ledger hash.

Each posted diff contains a source ledger hash, a target ledger hash, a list of account updates indexed by position, and an optional command payload.
Operationally, the critical write path is `Post_diff`: this is where the node verifies consistency and decides whether to sign.
The remaining RPCs (`Get_diff`, `Has_diff`, `Get_diff_source`, `Get_signature`, and the chain endpoints) are read and retrieval utilities that let clients reconstruct history and fetch attestations.

The core operation is `Post_diff`, which accepts a client-supplied diff and, if it passes validation, stores it and returns a signature over the target ledger hash and account-set root. The node's validation pipeline for this operation is as follows:

1. It checks that the provided openings match the claimed source ledger root.
2. It checks that the source is known locally (or allowed as an empty-ledger start).
3. It checks that diff indices are unique.
4. It applies each account update, enforcing index consistency, and recomputes the target ledger root.
5. It signs a message that commits to both the recomputed target ledger root and the account-set root.
6. If a command is present, it recomputes receipt-chain updates and checks they match the target ledger accounts.
7. It checks that newly created ledger accounts appear in the same order as their account-set entries.
8. It attaches metadata (timestamp and account-set root) to the stored diff.
9. It stores the diff under the target ledger hash, unless that target is already present in the database.

This flow is the main defense against a malicious sequencer trying to obtain signatures for states that cannot be reconstructed from posted data.
In particular, the signature is gated by local replay of all the account updates.
The optional command payload is checked through receipt-chain validation, which ties command execution to the resulting account receipt-chain hashes in the target ledger.

### Sequencer design

The sequencer is the main off-chain component that accepts user transactions, updates local state, pushes diffs to the DA layer, and proves commits that are posted on the L1.
Operationally, its role is to maintain a consistent pipeline from transaction intake to final commit while keeping enough local and DA-backed history to recover after failures.

A transaction first enters through GraphQL, where the sequencer checks pool constraints, fee requirements, proof/signature validity, and slot-range admissibility.
It is important that this is the only place where fees are checked, as the circuits never enforce any fee requirement, and thus the sequencer is responsible for choosing the right fee policy and enforcing it. Notice that even a malicious sequencer will try to enqueue as many transactions as possible to collect as much fee revenue as possible.
The slot acceptance window is a safety buffer: it reduces the risk of proving transactions that expire before they can be committed. Accepted transactions are applied to the local ledger and account-set state, producing witnesses for proving.

For DA, the sequencer builds a diff per applied command and posts diffs to DA nodes in strict order. At commit time, the sequencer collects signatures for the latest target hash to satisfy the DA multisig condition enforced onchain.
Proving is decoupled through a message queue and a parallel merger. Base proofs are generated asynchronously, merged in the background, and committed in order by closing batches into commit-ready units.
The sequencer periodically closes the parallel merging queue to produce a final commit proof, which is posted on the L1.

## Findings

### L2 actions are not enforced to be stored in DA

- **Severity**: High
- **Location**: protocol

**Description**. In emergency DA, only atomic changes to account data are recorded. Actions posted on the L2 are not enforced to be available: only the updated action state of modified accounts is stored, which can make certain ASEs unprovable.

For example, consider an emergency commit that updates the inner action state on the outer account. If the user does not publish the L2 action data, any action state extension aiming to claim something about a previous action prior to the newly pushed action state becomes unprovable, since the action openings that lead to the current action state are unknown.

**Impact**. Consider the case of a withdrawal flow, which requires proving some account state extension on the inner action state. Suppose that some user had initiated a withdrawal from L2 by depositing funds into the inner bridge account and witnessing the corresponding action on the inner account.
If the sequencer enqueues more witnesses for other actions on the inner account without publishing openings for them, the user performing the withdrawal will not be able to complete it, since it will not have the necessary data to prove the Action State Extension.
This exact issue also holds for emergency commits.

**Recommendation**. We recommend enforcing the storage of actions in the Data Availability layer, both for the normal operations and for emergency commits.

**Client Response**. This issue has been addressed in [PR #397](https://github.com/zeko-labs/zeko/pull/397) by including actions in the Diff data structure, and checking that the new action state for account updates is computed correctly.

### Emergency DA does not enforce a strict ordering of transactions, allowing permuting the IMT tree leaves

- **Severity**: High
- **Location**: rule_emergency_da.ml

**Description.** Suppose we have the following two transactions:

- Account $B1$ sends some money to a new account $A1$. Call this transaction $T1$.
- Account $B2$ sends some money to a new account $A2$. Call this transaction $T2$.

Clearly, from a ledger state $A$, applying first $T1$ and then $T2$, or applying $T2$ and then $T1$ will lead to the same ledger state $C$, given that the positions of the new accounts in the ledger are identical.

![reordering](/img/reports/zeko-2/reordering.png)

In particular, if we want to do an emergency commit including both transactions, we can push to emergency DA actions either path
- `A -> B -> C`
- `A -> B' -> C`

However, the IMT **is sensitive to the ordering of new accounts**: they have to be inserted left to right in the same order of the transactions being processed.

**Impact.** An external observer looking at DA actions cannot distinguish a priori what the ordering of transactions is in one commit.

This mechanism can be extended to, say, 128 new accounts, which renders it infeasible to reconstruct the actual transaction ordering from the IMT root alone. An attacker that wants to disrupt emergency commits can follow these steps:

1. Construct $n$ transactions $T_1, T_2, \dots, T_n$ that create $n$ new accounts (e.g., $n = 128$)
2. Apply those transactions to the current ledger and IMT root state `(source_ledger, source_imt)`, leading to some `(target_ledger, target_imt)`
3. Sample a random permutation of the transactions
4. Push to emergency DA the path from `source_ledger` to `target_ledger` following the account updates of the permuted transactions.

Users observing emergency DA cannot efficiently recover the ordering of the transactions, and thus cannot recover the leaf content in the IMT.

**Recommendation.** We recommend enforcing a strict ordering of transactions in emergency DA actions, so that the IMT tree leaves are always inserted in the same order as the transactions are processed. This prevents attackers from permuting transaction orderings and ensures that the IMT root is consistent with the actual transaction sequence.

**Client Response**. This issue has been addressed in [PR #398](https://github.com/zeko-labs/zeko/pull/398) by expanding the emergency DA folder to also include account set transitions. The commit circuit in emergency mode requires that both the ledger path and the account set transition path have been written to emergency DA.

### Unconstrained initializer in emergency DA folding

- **Severity**: High
- **Location**: emergency_da_folder.ml

**Description**. The emergency DA folder's `init` function applies no constraints to the initial statement:

```ocaml
let init ~check:_ (x : Init.var) = Checked.return x
```

The `init_arg` field of the folder witness is therefore a free, prover-chosen value. In the folder code, the `get` function calls `get_full` but discards the source:

```ocaml
let%snarkydef_ get ?check t =
  let*| `Source _source, `Target target, verify = get_full ?check t in
  (target, verify)
```

so the caller never sees or constrains what the initial state was.

**Impact.** This behaviour, combined with the ability to set `use_t = false` to skip recursive proof verification entirely, allows a malicious prover to supply an arbitrary `init_arg` (carrying any `source_ledger`, `target_ledger`, and `target_action_state`), omit any real recursion, and obtain that arbitrary statement as output.

When the commit rule calls `Emergency_da_inst.get emergency_da`, the returned `emergency_da_stmt` carries the forged ledger hashes and action state. To make subsequent constraints in the emergency commit rule pass, the prover only needs to set `target_action_state` to the current action state of the emergency DA account, bypassing the DA check entirely.

**Recommendation**. We recommend constraining the initial statement to have the initial source ledger hash equal to the initial target ledger hash.

**Client Response**. This issue has been addressed in [PR #395](https://github.com/zeko-labs/zeko/pull/395) by constraining that `source_ledger` and `target_ledger` are equal in the initialization step.

### DA nodes unsafely deserialize sparse ledgers

- **Severity**: High
- **Location**: da_layer/lib/rpc.ml

**Description.** The DA node's `post_diff` function accepts a client-supplied `ledger_openings` (a `Sparse_ledger.t`) and validates it by checking that its Merkle root matches the diff's `source_ledger_hash` ([core.ml:18-31](https://github.com/zeko-labs/zeko/blob/f04b7ff354b447f74da5de45241deab9cf982501/src/app/zeko/da_layer/lib/core.ml#L18-L31)).
The sparse ledger data structure contains a tree of nodes, and in particular for each internal node, it contains a **cached hash** for the subtree. The `merkle_root` function ([sparse_ledger.ml:134](https://github.com/zeko-labs/zeko/blob/f04b7ff354b447f74da5de45241deab9cf982501/src/lib/sparse_ledger_lib/sparse_ledger.ml#L134)) simply delegates to `hash`, which for a `Node(h, _, _)` returns the **stored** hash `h` without verifying it is consistent with the node's children ([sparse_ledger.ml:122-128](https://github.com/zeko-labs/zeko/blob/f04b7ff354b447f74da5de45241deab9cf982501/src/lib/sparse_ledger_lib/sparse_ledger.ml#L122-L128)):

```ocaml
let hash = function
    | Account a -> Account.data_hash a
    | Hash h -> h
    | Node (h, _, _) -> h
```

In particular, no recursion is done, and no checks are done to ensure this hash is consistent with the openings.

**Impact.** A malicious client can construct a sparse ledger tree whose root stores the honest `source_ledger_hash` but whose children are internally inconsistent. For example, they could store in the top node `Node(honest_root, honest_left_subtree, dishonest_right_subtree)` where `dishonest_right_subtree` contains a `Hash` or `Node` with a hash that does not correspond to any real ledger content. The root hash check at step 1 passes because `merkle_root` returns the cached `honest_root`. The client can post a diff with this dishonest tree, including an account update in the left subtree. When computing the new root, the `set_exn` function navigates down to the account, replaces it, and recomputes all ancestor hashes on the way up via `Node(Hash.merge ~height:i (hash l) (hash r), l, r)`. Critically, `hash r` on the untouched right subtree returns the dishonest hash, so the recomputed root now commits to `merge(updated_left_hash, dishonest_right_hash)`.

The resulting `target_ledger_hash` now incorporates the attacker-chosen right subtree, and the DA node signs it. As a result, the dishonest client has obtained a DA signature over a ledger state that includes arbitrary account data in the right subtree, without ever having posted valid account updates for those accounts. No other step in `post_diff` prevents this: the receipt chain hash check only validates accounts present in `changed_accounts`, so the dishonest accounts hidden in the right subtree are never inspected.

**Recommendation**. We recommend validating the sparse ledger data structure to be internally consistent. Alternatively, we recommend to replace the sparse ledger with a safely serializable structure, and then let the node construct a sparse ledger itself.

**Client Response**. This issue has been addressed in [PR #396](https://github.com/zeko-labs/zeko/pull/396) by providing a new `merkle_root_without_cache_exn` function that ignores cached values in the intermediate nodes, and instead recomputes the full hash from terminal leaves and subtree hashes.

### Users can censor storage of commands in archive nodes

- **Severity**: Medium
- **Location**: archive_relay.ml

**Description.** The archive relay periodically sends information to archive nodes. This information is taken from DA nodes, but looking at the diff data type, the commands stored in DA nodes are not guaranteed to be present.

```ocaml
type t =
  { source_ledger_hash : Ledger_hash.Stable.V1.t
  ; changed_accounts : (int * Account.Stable.V2.t) list
  ; command_with_action_step_flags :
      (User_command.Stable.V2.t * bool list) option
  ; timestamp : Block_time.Stable.V1.t
  ; acc_set : (Field.t[@version_asserted])
  }
```

**Impact.** When posting diffs, nothing prevents the client from setting the `command_with_action_step_flags` to `None`. Commands not published to DA are never stored in archive nodes.

This leads to two attacks, depending on the threat model:

- If the sequencer is honest, the command could be missing if a malicious user had already pushed a diff with the same target ledger hash to DA nodes, so that the honest one including the command sent by the sequencer gets ignored by DA nodes.
- If the sequencer is dishonest, it is free to not publish commands to DA, so they will never be stored by archive nodes.

**Recommendation.** We recommend enforcing that the command is present when posting diffs. Alternatively, we recommand letting the sequencer relay directly the relevant information to the archive nodes, without relying to DA nodes.

### DA nodes do not store diffs that lead to the same target ledger

- **Severity**: Medium
- **Location**: da_layer/lib/core.ml

**Description.** We start by describing two key behaviors of the DA nodes:

- If the target ledger hash is already present in the DB, DA nodes do not store it, but still return the signature to the client for the target ledger hash and target IMT root.
- The IMT openings are stored "implicitly" from the chain structure of the diffs.

Suppose we push the diff chain `A -> B -> C -> D`. Then we reorder transactions, and we also push the chain `A -> B' -> C' -> D` (see picture below).

![ignored_diff](/img/reports/zeko-2/ignored_diff.png)

The last diff `C' -> D` is not stored, because there is already a path to `D`. However, the sequencer retrieves a signature and can commit using the signature from the `A -> B' -> C' -> D` path. Notice that the IMT roots could be different for the same target ledger `D`, depending on what path we follow to get to the target ledger hash.

**Impact.** In the situation described above, clients connecting to DA nodes asking for the diff chain from `A` to `D` will retrieve the "dishonest" one `A -> B -> C -> D`. This state is manually recoverable, because the diffs that led to `B'` and `C'` are still stored in the database, but requires manual intervention by DA node operators.

**Recommendation.** One possible mitigation for this issue is to index the target state by both the target ledger hash and the target IMT root, so that different paths leading to the same target ledger hash but different IMT roots are stored and retrievable.

### Unsigned 32-bit element overflow in index_of_path

- **Severity**: Medium
- **Location**: rule_emergency_da.ml

**Description**. The function [`index_of_path`](https://github.com/zeko-labs/zeko/blob/f04b7ff354b447f74da5de45241deab9cf982501/src/app/zeko/circuits/rule_emergency_da.ml#L48-L56) reconstructs a ledger account index from a Merkle authentication path by accumulating `1 << height` for each right-hand step into a `Checked32` accumulator — a 32-bit unsigned integer in-circuit. The ledger depth is [configured to 35](https://github.com/zeko-labs/zeko/blob/f04b7ff354b447f74da5de45241deab9cf982501/src/app/zeko/zeko_constants.ml#L8), so valid account indices span from 0 to $2^{35} - 1$. At heights 32, 33, and 34, the OCaml expression `Checked32.of_int (1 lsl height)` overflows the 32-bit type and evaluates to 0, meaning those three high-order bits are never accumulated. As a result, any account with an index greater than or equal to $2^{32}$ cannot be witnessed through this circuit: the index reconstructed from the path will not match the actual account index.

**Impact**. A malicious sequencer could store a new account at an index greater than $2^{32}$, which in case of an emergency commit would be locked, since openings cannot be witnessed, and cannot be pushed to emergency DA actions.

**Recommendation**. We recommend changing the accumulator type to a larger unsigned integer type (e.g., `Checked64`) that can accommodate the full range of valid account indices without overflow. This ensures that all accounts, regardless of their index, can be properly witnessed and included in emergency DA actions when necessary.

**Client Response**. This issue has been addressed in [PR #399](https://github.com/zeko-labs/zeko/pull/399) by changing the ledger index type to `Checked64`.

### User can request arbitrary action state witness proofs from the Sequencer, allowing Denial of Service

- **Severity**: Medium
- **Location**: sequencer

**Description.** Action witness proofs for deposit and withdrawal requests are produced by the sequencer/prover on demand, and the proof is stored in memory for clients to download and wrap into a full on-chain transaction. These requests do not appear to carry fees or authorization at the time they are made. As a result, a caller can request an arbitrary number of proofs without committing to submit a corresponding transaction, effectively offloading proof computation to the sequencer with no cost.

**Impact.** A malicious client can spam proof requests and consume prover resources, which can degrade service quality or delay legitimate requests. Even if individual proofs are relatively small, the cumulative cost can be significant, and the attack does not require posting transactions on-chain.

**Recommendation.** We recommend gating action witness proof generation behind fees, quotas, or authentication, and rate-limit or prioritize requests to prevent prover resource exhaustion.

**Client Response**. Addressed in [PR #408](https://github.com/zeko-labs/zeko/pull/408) and [PR #412](https://github.com/zeko-labs/zeko/pull/412) by requiring users to pre-sign the relevant account update and by letting the sequencer pre-verify the bridge operation before starting proof generation.

### DA nodes are not enforced to be synchronized, and no authentication is required for posting diffs

- **Severity**: Low
- **Location**: deploy.ml

**Description**. There is no authentication when pushing diffs to DA nodes, so any caller can post a diff rather than only the sequencer. In addition, there is no mechanism to keep DA nodes synchronized, so different nodes can accept different diff paths that lead to the same target state.

**Impact**. Untrusted parties can inject diffs into DA nodes, and inconsistent histories across nodes can make it difficult to reason about the canonical command history or detect manipulation, even when the final state matches.

**Recommendation**. Require authentication or sequencing authorization for posting diffs, and define a synchronization or canonicality rule so DA nodes converge on a consistent diff history.

### Rollup outer account permissions allow signature-authorized state and VK changes

- **Severity**: Low
- **Location**: deploy.ml

**Description.** The proof_permissions definition in the deployment code sets `edit_state = Either` and `set_verification_key = (Either, ...)` on the outer rollup account, inner account, and holder accounts. This means the deployer private key can modify critical rollup state fields `(ledger_hash, sequencer, da_key, pause_key, acc_set)` and swap the verification key without producing a SNARK proof, bypassing all circuit-enforced invariants. In a key compromise scenario, an attacker could overwrite the ledger hash or swap the VK to a trivial circuit and drain bridged funds.

**Impact.** In practice, the impact is small because the deployer key is operationally controlled by the team and is required for legitimate administrative functions such as VK upgrades and state parameter changes. The design docs explicitly acknowledge this tradeoff (see for example [rollup-centralized-explanation.md](https://github.com/zeko-labs/zeko/blob/344fccee7b4e2e67c66999a9b1bebbb2431d0328/src/app/zeko/circuits/design/rollup-centralized-explanation.md#forced-account-update--governance-unimplemented)), and the system is currently operated as a centralized sequencer where the team already holds equivalent trust. The `set_permissions = Proof` guard does not provide additional protection since `set_verification_key = Either` allows swapping to a VK whose circuit can change permissions.

**Recommendation.** When the system matures past its early operational phase, we recommend upgrading governance into a dedicated circuit branch within Outer_rules (e.g., timelock or multisig-gated VK rotation), then set `edit_state = Proof` and `set_verification_key = (Impossible, ...)` on the outer account. This eliminates the single-key trust assumption without sacrificing upgradability.

### Field Underflow in DA Multisig Quorum Check

- **Severity**: Informational
- **Location**: multisig.ml

**Description.** The check function in DA multisignature verification converts `count >= quorum` into `count > quorum - 1` using field arithmetic.

```ocaml
Comparison_gadget.assert_greater_than_full valid_signatures_count
  (* sub 1 to do the >= *)
  Field.Var.(sub quorum (constant Field.one))
```

When `quorum = 0`, the subtraction wraps to $p−1$ (the Pallas field modulus minus one), making the assertion `count > p - 1` impossible to satisfy for any field element. The commit circuit becomes unsatisfiable and no valid proof can be generated.

**Impact.** Quorum is never validated to be positive at any layer — CLI, deploy, DA client, or circuit. An operator deploying with `--da-quorum 0` permanently bricks the normal operations of the rollup. Recovery requires emergency commits or redeployment.

**Recommendation.** We recommend implementing a sanity check in deployment for the quorum value so it is not zero. If a zero-count quorum has to be supported, adding one to `signature_count` instead of subtracting one from `quorum` can be an alternative remediation.

---

This report was published on the [zkSecurity Audit Reports](https://reports.zksecurity.xyz) site by [ZK Security](https://www.zksecurity.xyz), a leading security firm specialized in zero-knowledge proofs, MPC, FHE, and advanced cryptography. For the full list of audit reports, see [llms.txt](https://reports.zksecurity.xyz/llms.txt).
