# Audit of Mina's Decentralised Treasury

- **Date**: April 28th, 2026
- **Tags**: o1js, Mina

## Introduction

On February 9th, 2026, Mina Foundation tasked zkSecurity with auditing its decentralised on-chain community treasury. The audit lasted two weeks with two consultants. Alongside the [GitHub repository](https://github.com/maht0rz/decentralised-treasury), a [public request for comments](https://forums.minaprotocol.com/t/rfc-mina-decentralized-treasury-decentralized-on-chain-community-treasury/6924) on Mina's research forum was shared, outlining various aspects of the treasury design.

### Scope

The scope of the audit included the contracts and circuits in the `packages/sdk/src/provable` directory at commit [`f2c93cf`](https://github.com/maht0rz/decentralised-treasury/tree/f2c93cfdb27836b156769ca4eaeca7d5f01fa8b5). More specifically, we reviewed the following files:

- **contracts/treasury-constants.ts** Defines several important protocol constants, e.g., the size of the bond that needs to be deposited when creating a proposal.
- **contracts/treasury-owner.ts** Implements the `TreasuryOwnerSmartContract`, which manages the full governance lifecycle, i.e., proposal creation, voting, vote tallying, proposal execution, and pause control.
- **contracts/treasury-proposal/treasury-proposal.ts** Implements the `TreasuryProposalSmartContract`, which manages individual proposal state, e.g., recipient, amount, status, etc.
- **contracts/treasury-proposal/vote-reducer.ts** Implements the `VoteReducer` ZkProgram, which tallies vote actions.
- **contracts/treasury-pause-controller/treasury-pause-controller.ts** Implements the `TreasuryPauseControllerSmartContract`, which allows the admin multisig to emergency-pause both individual proposals as well as the entire treasury all at once.
- **contracts/treasury-pause-controller/multisig-signatures.ts** Defines the `MultisigSignatures` struct as well as certain multisig-utilities, such as verifying that the threshold of valid signatures is met against a Poseidon commitment to the participant set.
- **merkle-tree/prefixed-merkle-tree.ts** Implements the `PrefixedMerkleTree` class and `PrefixedMerkleWitness` for Merkle inclusion proofs.
- **account.ts** Defines an `Account` struct that mirrors the Mina L1 account layout, along with helper types.
- **hashing-helpers.ts** Provides Poseidon hashing utilities that are used throughout the protocol.
- **voting-account.ts** Defines the `VotingAccount` struct (currently a balance wrapper), representing a delegate's aggregated voting power in the voting ledger.
- **staking-ledger-to-voting-ledger.ts** Implements the `StakingLedgerToVotingLedger` ZkProgram, which transforms Mina's staking ledger into a voting ledger.

## Overview

### The Mina Protocol

Mina is a Layer 1 blockchain that uses recursive zero-knowledge proofs to create a constant-size proof of state. Unlike traditional blockchains where nodes must replay the full transaction history to verify the chain, Mina nodes only need to verify a single succinct proof. Smart contracts on Mina are written in TypeScript using [o1js](https://github.com/o1-labs/o1js). When a user interacts with a smart contract, the computation is performed off-chain and a proof of correct execution is generated. Mina then verifies this proof without needing to re-execute the computation itself.

Mina uses a version of the [Ouroboros Praos](https://eprint.iacr.org/2017/573.pdf) proof-of-stake consensus mechanism in which MINA token holders can delegate their stake to block producers. The Mina protocol maintains the _staking ledger_, a snapshot of all account balances and their delegation relationships that determines block production rights and, in the context of the decentralised treasury, serves as the source of truth for voting power. Each account in the staking ledger records a balance and a delegate field: if an account delegates to another public key, that delegate accumulates the delegator's balance as voting weight for treasury governance.

### The Decentralised Treasury

Mina’s decentralised community treasury is an on-chain governance system that allows the Mina community to propose, vote on, and execute funding requests from a shared treasury. The treasury holds MINA tokens and can release funding to recipients whose proposals have been approved through a transparent, proof-based voting process.

#### Actors

The system involves five types of actors:

- **Proposers.** Proposers submit funding requests during a designated proposal period, specifying a requested amount and a reference to the actual proposal content. To prevent spam, proposers must put up a bond (currently one-tenth of the requested amount), which is returned alongside the payout if the proposal is approved.
- **Voters.** In principle, all Mina accounts can vote. Voting weight is derived from the staking ledger at the time of the proposal's creation, so accounts that delegate to someone else have zero voting power. Note that it’s possible (and, in fact, the default) that a given account delegates to itself even when it does not actively participate in block production.
- **Provers.** Anyone who is willing to perform the computationally heavy zero-knowledge proof generation, e.g., to tally the votes for a given proposal, can be a prover. In practice, the treasury maintainers will take care of running these computations. However, it’s still important to note that anyone can call, e.g., `tallyVotes()` on the treasury owner contract, so in general, provers cannot be trusted.
- **Executors.** Anyone can execute an approved proposal as soon as the corresponding cooldown period has ended.
- **Multisig Signers.** Multisig Signers form a multisig committee with a predefined quorum (e.g., 3-out-of-5) and the authority to pause the treasury or individual proposals in case of an emergency.

#### Proposal Lifecycles and Their Periods

To get from submission to payout, proposals follow a repeating lifecycle of four fixed-length periods. Each period lasts `LIFECYCLE_PERIOD_DURATION = 7140` slots, exactly one staking epoch on Mina, which corresponds to approximately two weeks. A complete lifecycle therefore spans roughly eight weeks. Lifecycles repeat indefinitely, and each is identified by a sequential `lifecycleId` starting from 0.

The four periods are:

1. **Proposal Period.** Proposers submit new funding requests by calling `createProposal()` on the treasury owner contract. Each proposal specifies a recipient, a requested amount, and a reference to an actual description of the proposal. The proposer must also lock a bond, which is held by the treasury owner contract.
2. **Exploration Period.** This serves as a dedicated time window for the community to review, discuss, and evaluate active proposals before voting begins.
3. **Voting Period.** Delegates cast their votes by calling `vote()` on the treasury owner contract, which dispatches a `VoteAction` (yay, nay, or abstain) through the proposal contract. Votes are recorded as actions on-chain but are not tallied during this period. Instead, they accumulate as an action hash chain to be processed later.
4. **Cooldown Period (and beyond).** Once voting closes, the treasury enters the cooldown phase. During this period (or at any point after it), provers submit two kinds of zero-knowledge proofs: one that transforms the staking ledger into a voting-power ledger (`StakingLedgerToVotingLedger`), which can be reused for all proposals of the same lifecycle. And one proof per proposal that processes all vote actions into a final tally (`VoteReducer`). For every proposal, the action state is first validated against on-chain actions and stored via `commitActionState()`, and then `tallyVotes()` is called to verify both proofs and determine whether the proposal meets the required participation and approval thresholds. If approved, the proposal can be executed as soon as the next lifecycle starts. If rejected, the proposal cannot be executed. In particular, the bond will not be paid back to the proposer but will be kept in the treasury.

<img src="./img/lifecycle-periods.png" 
     style="width:100%; display:block; margin:auto;">

Enforcement of these periods is handled by the `requireLifecyclePeriod()` method on the treasury owner contract. Given a target `period` (0 = Proposal, 1 = Exploration, 2 = Voting, 3 = Cooldown) and a `lifecycleId`, the method computes the valid slot range as follows:

<!-- prettier-ignore -->
```tsx
// LIFECYCLE_PERIOD_DURATION = 7140
// NUMBER_OF_PERIODS = 4
fromSlot = treasuryDeployedAtSlot
         + (LIFECYCLE_PERIOD_DURATION * NUMBER_OF_PERIODS * lifecycleId)
         + (LIFECYCLE_PERIOD_DURATION * period)

toSlot   = fromSlot + LIFECYCLE_PERIOD_DURATION;
```

The contract then asserts that the current network slot falls within `[fromSlot, toSlot)`. For actions that should be permitted any time after a given period (such as tallying votes or executing proposals), the `noUpperBoundToSlot` flag is set to `true`, which replaces `toSlot` with `UInt32.MAXINT()`, effectively removing the upper bound.

#### Treasury Architecture and Main System Components

<div style="text-align: center;">
  <img src="./img/architecture.svg" style="width: 100%;">
</div>

The treasury is composed of three smart contracts and two ZkPrograms that work together to enable proposal creation, voting, tallying, and execution. The contracts handle state management and access control, while the ZkPrograms are used to “outsource” computationally heavy work (such as transforming the staking ledger into voting power and aggregating votes) to the provers.

The **TreasuryOwnerSmartContract** is the main component and user entry point of the treasury. It extends o1js's `TokenContract`, which means it manages a custom token whose token accounts serve as individual proposal instances (more on that below). Every user action (creating a proposal, casting a vote, tallying, executing, pausing) is made through this contract. It enforces lifecycle period timing, verifies the ZK proofs, and holds the treasury's MINA balance. In particular, bonds will be collected in this contract and approved proposal funds will be paid out from it.

The **TreasuryProposalSmartContract** represents an individual proposal. It stores the proposal's parameters (such as recipient hash, amount, lifecycle ID), the snapshotted staking epoch data, the proposal's status (unknown, approved, rejected, or paused), and a running tally of paid-out amounts for partial execution. Each proposal is deployed as a token account under the treasury-owner contract's token ID, i.e., its state and permissions are governed by the parent token contract. In other words, each proposal has its own `TreasuryProposalSmartContract` representing it, and there is a single `TreasuryOwnerSmartContract` instance that acts as the “owner” or “manager” of all of these proposals. In particular, users cannot call any of the methods in a given `TreasuryProposalSmartContract` directly. Instead, a proposal contract’s methods are always called through the respective user entry point in the owner contract. For example, while both the proposal contracts and the owner contract have a `vote()` method, users can only call `vote()` on the owner, which then subsequently invokes the `vote()` method on the proposal contract instance in question.

The **TreasuryPauseControllerSmartContract** provides an emergency pause mechanism that the multisig signers can trigger in the case of a security incident. It maintains a global pause flag and a Poseidon commitment to the valid multisig signers. In addition to enabling a _global_ pause of the entire treasury, it also takes care of the signature verification required when pausing _individual_ proposals.

The **StakingLedgerToVotingLedger** ZkProgram provably transforms Mina's staking ledger into a voting ledger for the treasury. It processes accounts in fixed-size batches (`digest()`), supports recursive proof merging (`merge()`), and includes a termination proof (`exhaust()`) that demonstrates the entire staking ledger has been consumed.

The **VoteReducer** ZkProgram processes vote actions dispatched by the proposal contract during the voting period. It performs the reduction in fixed-size batches (`reduceBatch()`), verifies each voter's inclusion in the voting ledger, prevents double-votes via nullifiers, and accumulates weighted tallies for yay, nay, and abstain. Similar to the staking to voting ledger transformation, it uses recursive proof merging to combine the voting results across batches.

#### Example Interaction Flow

The following sequence diagram illustrates a proposal’s typical lifecycle from creation to payout, and shows which method calls and contracts are involved in the process.

<div style="text-align: center;">
  <img src="./img/proposal-lifecycle.svg" style="width: 100%;">
</div>

There are a few things worth emphasizing:

- Every user call, whether initiated by a proposer, voter, prover, or executor, is submitted as a transaction to the treasury owner contract. The proposal and pause controller contracts are never called directly.
- Neither tallying nor execution requires a privileged role. Anyone who can generate valid proofs can submit a tally, and anyone [can trigger execution](#finding-low-anyone-can-execute-proposal) of an approved proposal.
- The `StakingLedgerToVotingLedger` proof has to be created once per epoch for all proposals, while `VoteReducer` has to be proved for every proposal after the voting period closes. This is where the majority of the computational work happens, namely recursively batching and merging proofs off-chain. The corresponding on-chain method calls (`commitActionState()` and `tallyVotes()`) then just need to verify that the ledger transformation and vote reduction were done correctly by verifying the corresponding proofs.
- Vote tallying is split into two separate transactions: `commitActionState()` first commits the action state hash derived from the vote reducer proof, and `tallyVotes()` then verifies both proofs and determines the outcome. The split was done as a workaround and is awkward, and we [propose how to merge them back together](#finding-info-commit-and-tally-could-be-merged).

#### Heavy SNARKing: Staking to Voting Ledger Transformation

An interesting component of the system is the `StakingLedgerToVotingLedger` proof, which provides voting weights for all Mina accounts. To understand how it works, note that Mina accounts can be represented as follows:

```typescript
type Account = {
  publicKey: PublicKey;
  tokenId: Field;
  balance: UInt64;
  delegate: PublicKey;
  // ... other fields
};
```

The "staking ledger" is conceptually just a list of all accounts on Mina (as a Merkle tree, which we ignore in our pseudo-code):

```typescript
type StakingLeger = Account[];
```

This data structure lets us look up (and prove efficiently) the `balance` and `delegate` for any given account.

However, for voting in the treasury, we want a different piece of information: The total `balance` of _all accounts_ that delegate to a given `delegate`.

Doing voting at the level of delegates, rather than individual accounts, makes sense: it means the voting power is distributed across a reasonably diverse, but medium-sized, mostly well-known set of community members, which can on average be assumed to be interested in the outcome.

To implement that idea, we need to know for every given voter how much Mina is delegated to them. Interestingly, even though Mina proof-of-stake is based on the same per-delegate weights, Mina doesn't expose this information in any form. Mina's own blockchain SNARK never needs it: The verifiable random function (VRF), which determines block production, picks a random account weighted by _account balance_ (not delegated balance). The `account.delegate` of the picked account is then the chosen block producer.

For treasury voting, roughly the same could be done: each `delegate` could vote "from" each account that delegates to them; but that would require huge numbers of transactions and a high on-chain cost of each proposal vote. Much better would be if each delegate could vote just once, for the _entire_ stake they control.

To make that feasible, we need a map from delegates to total stake. We call this map the _voting ledger_. In pseudo-code, it could be constructed as follows:

```typescript
type VotingLedger = Map<PublicKey, UInt64>;

function stakingToVotingLedger(stakingLedger: StakingLedger): VotingLedger {
  let votingLedger = new Map<PublicKey, UInt64>();

  for (let account of stakingLedger) {
    if (account.tokenId !== MINA) continue;

    let stake = votingLedger.get(account.delegate) ?? 0;
    stake += account.balance;
    votingLedger.set(account.delegate, stake);
  }

  return votingLedger;
}
```

On Mina, the `stakingLedger` has on the order of 300,000 accounts, and the computation above has to be done inside a SNARK: array access as well as `Map.get()` and `set()` become Merkle tree lookups that hash to the root. This is an immense computation, and has to be done once per epoch by the treasury operators. In the most optimistic estimate, it is expected to complete in ~3 days on 10 parallel VPS for a cost of ~$50. In one our findings, we [contribute two ideas](#finding-info-proof-efficiency-staking-to-voting) to make the computation more efficient. We believe that both of them combined can reduce proving costs by a factor of 30!

## Vote Throughput Analysis

As part of the audit engagement, we evaluated whether the decentralised treasury's voting mechanism can practically accommodate enough voters to meet the governance acceptance criteria. The answer is **yes**. Stake on Mina is so heavily concentrated that the participation threshold can be met with as few as 2-6 votes from the largest delegates, and the on-chain throughput ceiling (~85K transactions per voting period) is more than sufficient for realistic voter turnout. However, the throughput constraint does mean that participation by *all* ~282K staking ledger accounts is impossible.

### On-Chain Vote Throughput Ceiling

#### Parameters

| Parameter | Value | Source |
|:----------|:------|:-------|
| Staking ledger accounts (potential voters) | ~282K | [MinaScan](https://minascan.io) and epoch 42 staking ledger in [this Google Bucket](https://storage.googleapis.com/mina-staking-ledgers/staking-42-jxe9iNroVoV4veotTBNMUXXy1To929xBaq8gBeP976N5Vv1iUrB-78be34944bb1261040eefe91ea514355-2026-02-25_0720.json) |
| Voting period duration | 7,140 slots | `LIFECYCLE_PERIOD_DURATION` in `treasury-owner.ts` |
| Average block production rate | 50% | [Mina Whitepaper](https://minaprotocol.com/wp-content/uploads/technicalWhitepaper.pdf) (page 42, "f=0.5, the average fraction of filled slots in an epoch") |
| Max zkApp transactions per block | 24 | [Mina Docs](https://docs.minaprotocol.com/zkapps/writing-a-zkapp) |

#### Calculation

```jsx
Blocks per voting period     = 7,140 × 50% = 3,570
Max zkApp tx/voting period   = 3,570 × 24  = 85,680
```

The theoretical maximum of possible vote transactions in a given voting period is **~85K**. Under these ideal conditions (i.e., assuming that all Mina transactions in a given voting period are treasury votes), only ~85K / ~282K ≈ **30%** of accounts could vote.

In practice, though, not all block space will be available for votes. The treasury has to share the 24 tx/block limit with the entire Mina ecosystem. Additionally, multiple proposals can be active in a given voting period, making block space even scarcer. Realistic capacity is therefore significantly lower than 85K.

| Block Space Available for Voting | Possible Votes | % of Mina Accounts (~282K) |
|:---------|:---------------------|:---------------------|
| 100% (Theoretical max) | 85,680 | **30.4%** |
| 75% | 64,260 | **22.8%** |
| 50% | 42,840 | **15.2%** |
| 25% | 21,420 | **7.6%**  |

### Acceptance Criteria

The governance system uses two thresholds to determine whether a proposal passes (see `treasury-proposal.ts` and `treasury-constants.ts`):

**Participation threshold:** The total weight of all votes (yay + nay + abstain) must exceed a percentage of `stakingEpochDataLedgerTotalCurrency`. This percentage is determined by a curve that scales with the ratio of the proposal amount to the treasury balance:

| Proposal Size (relative to treasury) | Required Participation |
|:--------------------------------------|:----------------------|
| Small (ratio → 0) | 20% of total staked currency (`MIN_PARTICIPATION_BP = 2000`) |
| Large (ratio → 100%) | 50% of total staked currency (`MAX_PARTICIPATION_BP = 5000`) |

**Approval threshold:** Of the votes that are yay or nay (excluding abstain), the yay percentage must exceed a curve-based threshold:

| Proposal Size (relative to treasury) | Required Approval |
|:--------------------------------------|:-----------------|
| Small (ratio → 0) | 51% (`MIN_APPROVAL_BP = 5100`) |
| Large (ratio → 100%) | 70% (`MAX_APPROVAL_BP = 7000`) |

The important observation is that the participation threshold is **stake-weighted**. A single vote from a large delegate can contribute millions of MINA toward the participation threshold.

### Stake Concentration on Mina

To determine whether the throughput ceiling is a practical problem, we analyzed the current staking ledger (epoch 42, 2026-02-27), taken from this [public GCS bucket](https://storage.googleapis.com/mina-staking-ledgers/staking-42-jxe9iNroVoV4veotTBNMUXXy1To929xBaq8gBeP976N5Vv1iUrB-78be34944bb1261040eefe91ea514355-2026-02-25_0720.json).

#### Ledger Summary

| Metric | Value |
|:-------|:------|
| Total accounts | 282,292 |
| Total currency | 1,680,452,206 MINA |
| Unique delegates | 226,423 |

#### Top 10 Delegates by Delegated Stake

| Rank | Delegated Stake | % of Total |
|:-----|:---------------|:-----------|
| #1 | 251,496,429 MINA | 14.97% |
| #2 | 188,728,844 MINA | 11.23% |
| #3 | 180,273,839 MINA | 10.73% |
| #4 | 100,000,000 MINA | 5.95% |
| #5 | 99,844,618 MINA | 5.94% |
| #6 | 94,987,940 MINA | 5.65% |
| #7 | 51,846,715 MINA | 3.09% |
| #8 | 42,714,873 MINA | 2.54% |
| #9 | 41,109,181 MINA | 2.45% |
| #10 | 26,367,387 MINA | 1.57% |

#### Cumulative Stake Concentration

| Top N Delegates | Cumulative % of Total Stake |
|:---------------|:---------------------------|
| Top 1 | 14.97% |
| Top 5 | 48.82% |
| Top 10 | 64.11% |
| Top 20 | 75.88% |
| Top 50 | 90.10% |
| Top 100 | 95.87% |
| Top 200 | 98.11% |
| Top 500 | 99.31% |
| Top 1,000 | 99.66% |

#### Delegates by Balance Tier

| Balance Tier | # of Delegates | Cumulative Stake |
|:-------------|:--------------|:----------------|
| >= 10,000 MINA | 759 | 99.6% |
| >= 1,000 MINA | 2,727 | 99.9% |
| < 1,000 MINA | ~223,696 | < 0.1% |

### Can the Chain Handle Enough Votes to Pass Proposals?

#### Minimum Participation Threshold

For a small proposal, the participation threshold is 20% of total staked currency:

```jsx
Required participation = 1,680,452,206 × 20% = 336,090,441 MINA
```

From the stake-concentration data:
- **Top 2 delegates** already hold 26.20% of stake (440,225,273 MINA), which **exceeds the 20% threshold**.

For a large proposal (50% participation required):
- **Top 6 delegates** hold 54.47% of stake, which **exceeds the 50% threshold**.

So, even in the most extreme scenario (50% participation for a large proposal), this requires only **6 vote transactions**, which obviously fits within the ~85K capacity.

Realistically, we can expect a few hundred to a few thousand votes at most. Only ~759 delegates have >= 10,000 MINA (with meaningful economic interest), and ~2,727 have >= 1,000 MINA. The overwhelming majority of the ~226K delegates have negligible balances and little incentive to vote. Even if all 2,727 delegates with >= 1,000 MINA vote, that's 2,727 transactions, i.e., **3.2% of the theoretical capacity**.

#### Multi-Proposal Contention

With multiple proposals active in the same voting period, block space is shared:

| Proposals | Votes/Proposal | Total Txns | % of Capacity |
|:----------|:--------------|:-----------|:-------------|
| 1 | 2,727 | 2,727 | 3.2% |
| 5 | 2,727 | 13,635 | 15.9% |
| 10 | 2,727 | 27,270 | 31.8% |

Even with 10 concurrent proposals and every delegate with >= 1,000 MINA voting on every single one, block space utilization is only ~32%.

### Analysis Takeaways

**Vote throughput is not a practical bottleneck.** Stake is so heavily concentrated on Mina that the participation threshold (20-50% of total staked currency) can be met with as few as 2-6 votes from the largest delegates. Realistic voter turnout (~hundreds to low thousands) is well within the ~85K capacity ceiling.

**Universal participation is impossible.** Only ~30% of the ~282K accounts could vote even if all block space were dedicated to votes. However, this is not a functional problem because the >99% of accounts with < 1,000 MINA collectively hold < 0.1% of stake and have negligible impact on governance outcomes.

## Summary and Recommendations

Overall, we found the treasury design to be sound. Our security-related findings can be found [below](#findings). Apart from these, the following general recommendations are intended to help the team further mature the project ahead of the launch.

**Add more documentation.** Currently, the project has a fairly minimal README that primarily contains CLI usage examples for a local Lightnet setup, with some sections still marked as TODO. The project repo currently does not contain any architectural documentation explaining the governance lifecycle, the relationship between the three smart contracts, or the role of the two main ZkPrograms. A more extensive documentation of the treasury would improve maintainability and transparency, and would make future security audits more efficient.

**Expand test coverage.** While the code in scope does come with tests for the core contracts and ZkPrograms, we recommend expanding the current coverage even further. For example, the primary test for the governance lifecycle (_treasury-owner.test.ts_) is structured as a single sequential happy path: deploy, create proposal, vote, commit action state, tally, and execute. However, there are currently no negative test cases. For example, there are no tests verifying that proposal creation is rejected outside the `PROPOSAL` period, that voting fails outside the `VOTING` period, and that a `REJECTED` proposal cannot be executed.

**Enable strict TypeScript checking.** The project does not currently have strong enforcement of type checking. We recommend enabling `strict: true` in `tsconfig.json`, adding a dedicated type-check command to the SDK package, and fixing all existing and resulting type errors. This is a low-effort way to catch bugs early, such as unintended `undefined` values or incorrect type narrowing.

## Findings

### Action State History `found` Flags Are Prover-Controlled, Allowing Fabricated Votes

- **Severity**: High
- **Location**: contracts/treasury-proposal/vote-reducer.ts

<!-- ZKS ISSUE HIGH vote-reducer.ts:370
  The merge method propagates `found` flags from inputs without ever validating them.
  A prover can set all action state history entries to found in the inputs, bypassing actual discovery.
-->

**Description.** The `VoteReducer` ZkProgram tracks an `actionStateHistory` of five action state hashes paired with `found` flags. As the reducer processes vote actions in `reduceBatch()`, it builds up a `toActionsHash` chain and, whenever `toActionsHash` matches one of the five hashes, marks that entry as `found`. In `merge()`, the flags are combined as `output.found = output1.found || output2.found`. Downstream, `commitActionState()` requires all five entries to be `found`:

```js
actionState.found.assertTrue("Action state not found in the merkle list");
```

The problem is that `actionStateHistory`, including the `found` flags, is part of the `VoteReducerPublicInput`, i.e., it is supplied by the prover:

```js
export class VoteReducerPublicInput extends Struct({
  fromActionsHash: Field,
  votingLedgerRoot: Field,
  fromNullifierRoot: Field,
  actionStateHistory: ActionStateHistory, // <-- prover-controlled
}) {}
```

In `reduceBatch()`, the history is initialized directly from this input:

```js
let actionStateHistory = ActionStateHistory.clone(
  publicInput.actionStateHistory,
);
```

Since `found` flags are never reset to `false`, a prover who sets all five `found` values to `true` in the input will see them persist through both `reduceBatch()` and `merge()` unchanged. The final proof output has all entries marked as found regardless of whether the reducer's `toActionsHash` ever actually matched any of them.

**Impact.** Together with [a separate finding on missing a link between action state history and `toActionsHash`](#finding-high-action-state-history-not-linked-to-actions-hash), this completely breaks the connection between the vote reducer proof and the actual on-chain actions. A malicious prover can process a fabricated set of vote actions — for any real accounts in the voting ledger — that were never actually dispatched on-chain, giving the prover full control over the governance outcome.

**Recommendation.** Remove the `found` flags from the public input. Each `reduceBatch()` invocation should start with all `found` values hardcoded to `false`. The existing `merge()` logic (`output1.found || output2.found`) already operates on outputs only and would remain correct.

**Client response.** Acknowledged and fixed in commit [37ca9850a61767dad9036ac98ea82895919c9c6a](https://github.com/maht0rz/decentralised-treasury/commit/37ca9850a61767dad9036ac98ea82895919c9c6a).

### Action State History Not Linked to `toActionsHash`, Allowing Fabricated Votes

- **Severity**: High
- **Location**: contracts/treasury-owner.ts

<!-- ZKS ISSUE HIGH treasury-owner.ts:441
  toActionsHash is committed without proving it equals the latest validated history state.
  A prover can include actions beyond the validated history and fabricate votes.
-->

**Description.** The governance flow uses a two-step process: first `commitActionState()` validates the vote reducer proof's action state history and stores its `toActionsHash`, then `tallyVotes()` uses the committed `toActionsHash` to accept a vote reducer proof and determine the governance outcome.

In `commitActionState()`, the action state history entries are validated against on-chain state, and then `toActionsHash` is stored in the proposal contract. However, nothing enforces that `toActionsHash` equals any of the validated history entries:

```js
// treasury-owner commitActionState():

// step 1: validate each history entry against on-chain state
for (const actionStateKey of Object.keys(
  voteReducerProof.publicOutput.actionStateHistory,
)) {
  // ...
  actionStateUpdate.account.actionState.requireEquals(actionState.hash);
  actionState.found.assertTrue("Action state not found in the merkle list");
  this.approve(actionStateUpdate);
}

// step 2: store toActionsHash — not linked to any validated history entry
await proposal.commitActionState(voteReducerProof.publicOutput.toActionsHash);
```

In `tallyVotes()`, a separately supplied vote reducer proof is accepted as long as its `toActionsHash` matches the previously committed value.

```js
// treasury-proposal tallyVotes():
this.toActionsHash
  .getAndRequireEquals()
  .equals(voteReducerPublicOutput.toActionsHash)
  .assertTrue("toActionsHash does not match on chain state");
```

Furthermore, `tallyVotes()` on the treasury owner checks that the tally proof's `toActionsHash` equals its `actionStateHistory.actionStateOne.hash`:

```js
// treasury-owner tallyVotes():
voteReducerProof.publicOutput.toActionsHash
  .equals(voteReducerProof.publicOutput.actionStateHistory.actionStateOne.hash)
  .assertTrue("toActionsHash does not match action state one hash");
```

However, this check is internal to the tally proof and practically meaningless, as the history entries passed to `tallyVotes()` are not validated against anything; in particular, not re-validated against on-chain state.

Overall, the `toActionsHash` committed in step one and used in step two is never required to correspond to an on-chain action state.
An attack is possible using two different proofs:

1. **Commit proof**: processes all real on-chain vote actions, then continues to append fabricated vote actions for arbitrary accounts in the voting ledger. The history entries use the real on-chain hashes (passing `commitActionState()`'s on-chain validation), and the extended `toActionsHash` is stored.
2. **Tally proof**: a separate proof whose `actionStateOne.hash` is set to the same extended `toActionsHash`. Both constraints — `toActionsHash == actionStateOne.hash` and `toActionsHash == this.toActionsHash` (the committed value) — are satisfied.

**Impact.** A malicious prover can fabricate vote actions for any account in the voting ledger that has not already voted on-chain, inflating the tallies. This gives the prover control over the governance outcome, potentially approving unauthorized treasury payouts.

Note that while this finding is an independent issue, our [finding on prover-controlled action state history](#finding-high-action-state-history-never-validated) makes it even worse, allowing a prover to control the _entire_ vote and not just append to it.

**Recommendation.** In `commitActionState(),` enforce that `toActionsHash` equals the latest validated action state history entry.

**Client response.** Acknowledged and fixed in commit [791401edf87e47d901ed4c76323fbfbf44b56c4a](https://github.com/maht0rz/decentralised-treasury/commit/791401edf87e47d901ed4c76323fbfbf44b56c4a).

### Action State Uniqueness Not Checked, Allowing Vote Discarding

- **Severity**: High
- **Location**: contracts/treasury-owner.ts

<!-- ZKS ISSUE HIGH treasury-owner.ts:422
  The action state history entries written during commitActionState are not checked for uniqueness.
  A prover can supply an older action state, causing newer votes to be discarded/overwritten.
-->

**Description.** The Mina protocol stores 5 action states per account: the current state as the transaction is being processed, plus snapshots from the tip of each of the last 4 blocks that affected this account's action state. A precondition `account.actionState.requireEquals(a)` passes if `a` matches any of these 5 values. In `commitActionState()`, the vote reducer proof's action state history contains 5 entries, each validated against the on-chain state via this precondition. The intent is for the 5 history entries to cover all 5 on-chain states, proving the reducer processed _every action_. However, the entries are never checked for uniqueness:

```js
for (const actionStateKey of Object.keys(
  voteReducerProof.publicOutput.actionStateHistory,
)) {
  const actionStateUpdate = AccountUpdate.create(
    proposalPublicKey,
    this.deriveTokenId(),
  );
  const actionState =
    voteReducerProof.publicOutput.actionStateHistory[actionStateKey];

  // [ZKSECURITY] passes for ANY of the 5 on-chain action states
  actionStateUpdate.account.actionState.requireEquals(actionState.hash);
  actionState.found.assertTrue("Action state not found in the merkle list");

  this.approve(actionStateUpdate);
}
```

Since the on-chain precondition accepts any of the 5 stored states, a prover can set all 5 history entries to the same older action state. The reducer then only needs to process actions up to that point, and the resulting `toActionsHash` is committed — discarding all votes from later actions.

Furthermore, non-uniqueness means that `commitActionState()` can be called multiple times with different `toActionsHash` outcomes. An attacker can overwrite a previously committed `toActionsHash` by calling `commitActionState()` again with duplicate history entries pointing to an older state, disrupting an ongoing tally computation.

**Impact.** A malicious prover can selectively discard votes by committing to an older action state, manipulating the governance outcome in their favor. Additionally, an attacker can grief a legitimate tally in progress by overwriting the committed action hash.

**Recommendation.** Enforce that all 5 action state history entries are unique, ensuring the reducer proof covers all on-chain action batches.

**Client response.** Acknowledged and fixed in commit [791401edf87e47d901ed4c76323fbfbf44b56c4a](https://github.com/maht0rz/decentralised-treasury/commit/791401edf87e47d901ed4c76323fbfbf44b56c4a) and [60bfb4de7751a7b1957c98ec753194c5fab2f15e](https://github.com/maht0rz/decentralised-treasury/commit/60bfb4de7751a7b1957c98ec753194c5fab2f15e).

### Permissions Are Ineffective Due to o1js Deploy Behavior

- **Severity**: High
- **Location**: contracts/treasury-owner.ts

<!-- ZKS ISSUE MEDIUM-HIGH treasury-owner.ts:113
  Permissions are set in init() but this doesn't actually apply them due to unintuitive o1js behavior.
  deploy() would be needed, but the deploy() override is commented out.
  This is why the overly restrictive permissions (separate finding) were never noticed:
  they never took effect.
-->

**Description.** `TreasuryOwnerSmartContract` extends `TokenContract` and defines a restrictive custom permission set:

```js
public static permissions = {
  ...Permissions.allImpossible(),
  editState: Permissions.proof(),
  access: Permissions.proof(),
  incrementNonce: Permissions.proof(),
  setVerificationKey:
    Permissions.VerificationKey.impossibleDuringCurrentVersion(),
};
```

These permissions are applied in `init()`:

```js
public init() {
  super.init();
  this.account.permissions.set(TreasuryOwnerSmartContract.permissions);
  // ...
}
```

However, `TokenContract.deploy()` also sets permissions, using `Permissions.default()` with `access: proofOrSignature()`. Internally, `TokenContract.deploy()` calls `super.deploy()`, which runs `init()`, and then sets its own permissions afterwards, overwriting whatever `init()` set. The custom permissions from `init()` are therefore ineffective.

Thus, the effective permissions are `TokenContract`'s defaults, which include notably:

- `setPermissions: signature()`: the private key holder can change any permission
- `setVerificationKey: signature()`: the private key holder can replace the contract's verification key at any time
- `access: proofOrSignature()`: the private key holder can make unguarded changes to the proposal contracts, by including the owner contract via signature

This also explains why the overly restrictive permissions described in finding [Treasury owner permissions set `send` and `receive` to `impossible`](#finding-medium-send-receive-impossible) were never noticed during testing: they never took effect.

**Impact.** The contract's intended security model, i.e., that nearly all operations require proof authorization and that the verification key is immutable, is not enforced. In particular, with default permissions, modifying proposal accounts is not protected by the requirement to call treasury owner methods. For example, the private key holder of the treasury account can create proposals already in "APPROVED" status, which can be executed immediately to withdraw arbitrary amounts from the treasury. Setting the permissions and/or verification keys presents attack vectors. More subtle attacks, such as modifying a vote tally, are on the table as well, which might even be more pernicious since these might go unnoticed by the community.

**Recommendation.** Override `deploy()` instead of `init()` to set permissions, ensuring they take precedence over `TokenContract`'s defaults. The permission set itself also needs to be corrected (see [Treasury owner permissions set `send` and `receive` to `impossible`](#finding-medium-send-receive-impossible)).

**Client response.** Acknowledged and fixed in commit [791401edf87e47d901ed4c76323fbfbf44b56c4a](https://github.com/maht0rz/decentralised-treasury/commit/791401edf87e47d901ed4c76323fbfbf44b56c4a) and [c8fb37a8816a833d1cbeda6543c1fa63f2db3016](https://github.com/maht0rz/decentralised-treasury/commit/c8fb37a8816a833d1cbeda6543c1fa63f2db3016).

### Index Aliasing for Voting and Nullifier Ledgers Allows Double Voting

- **Severity**: High
- **Location**: merkle-tree/prefixed-merkle-tree.ts

<!-- ZKS ISSUE HIGH prefixed-merkle-tree.ts:235
  If height == 256, index overflows the 255-bit field (p < 2^255).
-->

**Description**. Both voting ledger and nullifier ledger convert a public key to a Merkle tree index by hashing it:

```js
Poseidon.hash(publicKey.toFields());
```

The result is a field element, an integer in $[0, q)$ where $q$ is the Pasta (Pallas) base field order:

$$
\begin{aligned}
q = \texttt{0x40000000000000000000000000000000224698fc094cf91b992d30ed00000001} \\
\quad \approx 2^{254} + 2^{125}
\end{aligned}
$$

A Merkle tree of height 256 has $2^{255}$ leaves, meaning valid indices span $[0, 2^{255})$. For **most** field elements $k \in [0, q)$, both $k$ and $k + q$ are valid tree indices because both are smaller than $2^{255}$.

In the circuit, `calculateIndex()` reconstructs the index from the sequence of `isLeft` bits that specify a Merkle path:

```js
// prefixed-merkle-tree.ts
calculateIndex(): Field {
  let powerOfTwo = Field(1);
  let index = Field(0);
  let n = this.height(); // [ZKSECURITY] n = 256

  for (let i = 1; i < n; ++i) {
    index = Provable.if(this.isLeft[i - 1], index, index.add(powerOfTwo));
    powerOfTwo = powerOfTwo.mul(2);
  }
  return index;
}
```

The result is checked against the index $k$ that belongs to a given public key, so effectively we verify

$$\sum_i \text{isLeft}[i] \cdot 2^i \equiv k \mod{q}.$$

Both $k$ and $k + q$ satisfy this check because $k + q \equiv k \bmod{q}$. The circuit cannot distinguish between the two indices — they map to the same field value. But in the underlying tree, they point to **different leaves**.

**Impact.**

- **Double Voting**. An attacker who has already voted (nullifier set at index $k$) can vote again using index $k + q$. The nullifier leaf at $k + q$ is empty (default), so the circuit sees no prior vote and accepts it. The attacker casts two votes with one identity.
- **Nerfed Voting Power**. In the voting ledger, a legitimate voter's balance is stored at index $k$. An attacker (or malicious operator) could present a Merkle witness at index $k + q$, which holds the default empty voting account (zero balance). This makes the voter appear to have no voting power.

**Recommendation.** Use a Merkle tree of height 255 instead of 256. This limits valid indices to $[0, 2^{254})$.

Since $q > 2^{254}$, keys in $[2^{254}, q)$ would become unmappable. However, the number of such keys is only

$$
q - 2^{254} \approx 2^{125}
$$

out of $\approx 2^{254}$ possible keys, a ratio of $\approx 2^{-129}$. This is cryptographically negligible: brute-forcing a public key hash into this range requires effort comparable to breaking 128-bit security.

**Client response.** Acknowledged and fixed in commit [c8fb37a8816a833d1cbeda6543c1fa63f2db3016](https://github.com/maht0rz/decentralised-treasury/commit/c8fb37a8816a833d1cbeda6543c1fa63f2db3016) and [397c77120544fb460d36350cbe06773419b0c0e1](https://github.com/maht0rz/decentralised-treasury/commit/397c77120544fb460d36350cbe06773419b0c0e1).

### UInt64 Overflow in Acceptance Criteria Causes Vote Tallying to Always Fail

- **Severity**: High
- **Location**: contracts/treasury-proposal/treasury-proposal.ts

<!-- ZKS ISSUE HIGH treasury-proposal.ts:271 (+ starred :109 and :291)
  Multiplying total currency (~10^18) by basis points (>=2000) produces >2^70, exceeding UInt64 range.
  tallyVotes can never succeed on mainnet. Same overflow affects approval calculation.
-->

**Description.** The `tallyVotes()` method computes participation and approval thresholds using `UInt64` arithmetic. Two multiplications inevitably overflow on Mina mainnet, where total currency is approximately $10^{18}$ nanomina:

**1. Participation threshold.** `stakingEpochDataLedgerTotalCurrency` (${}\approx 10^{18}$) is multiplied by `requiredParticipationBp` ($\geq 2000$):

```js
const requiredParticipation = stakingEpochDataLedgerTotalCurrency
  .mul(requiredParticipationBp)
  .div(BASIS_POINTS);
```

The intermediate product exceeds $2 \times 10^{21} > 2^{70}$, well above the `UInt64` maximum of $2^{64} - 1$.

**2. Approval ratio.** For a proposal to be accepted, `yay` votes must exceed roughly 10% of total currency ($> 10^{17}$). Multiplying by `BASIS_POINTS` (10,000):

```js
const approvalBp = yay.mul(BASIS_POINTS).div(totalVotes);
```

The intermediate product again exceeds $10^{21} > 2^{70}$.

In o1js, `UInt64.mul()` includes a range check that constrains the result to 64 bits. When the product exceeds this range, proof generation fails, i.e., the transaction becomes unprovable.

Additionally, `calculateAcceptanceCriteria()` contains a similar multiplication, `proposalAmount.mul(BASIS_POINTS)`, that overflows for proposal amounts above $\approx 1.8 \times 10^{15}$ nanomina ($\approx 1.8\text{M}$ MINA), which is realistic for a well-funded treasury. The remaining multiplications in `calculateAcceptanceCriteria()` operate on basis-point-scale values ($\leq 10000$) and are safe.

**Impact.** On Mina mainnet, `tallyVotes()` can never succeed: the participation check always overflows, and even if it didn't, the approval check overflows on all accepted proposals. The governance system is completely non-functional.

**Recommendation.** Use wider intermediate arithmetic, for example by performing the `UInt64` multiplication at the `Field` level and implementing an integer division similar to `UInt64.div()` that handles up to 128-bit numerators but constrains the result back to the UInt64 range. Implementing a full `UInt128` type is not needed.

Note that reordering to divide before multiplying does not work for two of the three sites: the approval ratio (`yay.div(totalVotes).mul(BASIS_POINTS)`) and the acceptance criteria ratio (`proposalAmount.div(treasuryBalance).mul(BASIS_POINTS)`) have numerators typically smaller than denominators, so dividing first yields zero in integer arithmetic.

**Client response.** Acknowledged and fixed in commit [0967b6803e80338b4143b76b0b9cfc4387f91065](https://github.com/maht0rz/decentralised-treasury/commit/0967b6803e80338b4143b76b0b9cfc4387f91065) and in [3d82d84092a67871207759133677733a3c613e94](https://github.com/maht0rz/o1js/commit/3d82d84092a67871207759133677733a3c613e94) of the [custom o1js fork](https://github.com/maht0rz/o1js).

### Unconstrained Initial Staking Ledger Index Allows Selective Voter Exclusion

- **Severity**: High
- **Location**: contracts/treasury-proposal/treasury-proposal.ts

<!-- ZKS ISSUE HIGH treasury-proposal.ts:193
  stakingLedgerToVotingLedgerPublicInput is not checked:
  index is unconstrained, allowing start from arbitrary position.
-->

**Description.** The `tallyVotes()` method does not constrain the starting index of the staking-to-voting ledger transformation proof. While the `StakingLedgerToVotingLedger` ZkProgram enforces internal continuity between chained proofs (`merge()` requires `proof1.output.index + 1 == proof2.input.index`) and proves that processing reached the end of populated accounts (`exhaust()` verifies the next index is empty), nothing enforces that the chain starts at index 0.

The `stakingLedgerToVotingLedgerPublicInput.index` is completely unconstrained in `tallyVotes()`.

**Impact.** An attacker can start the staking-to-voting transformation at an arbitrary index, excluding all accounts below that index from the voting ledger. This allows the attacker to selectively exclude opposing voters and manipulate the outcome in their favor.

**Recommendation.** Add the missing check in `tallyVotes()`:

```typescript
stakingLedgerToVotingLedgerPublicInput.index
  .equals(UInt64.from(0))
  .assertTrue("staking ledger transformation must start at index 0");
```

**Client response.** Acknowledged and fixed in commit [397c77120544fb460d36350cbe06773419b0c0e1](https://github.com/maht0rz/decentralised-treasury/commit/397c77120544fb460d36350cbe06773419b0c0e1).

### Unconstrained Initial Voting Ledger Root Allows Vote Weight Manipulation

- **Severity**: High
- **Location**: contracts/treasury-proposal/treasury-proposal.ts

<!-- ZKS ISSUE HIGH treasury-proposal.ts:193
  stakingLedgerToVotingLedgerPublicInput is not checked:
  initial voting ledger root is unconstrained, allowing pre-populated ledger.
-->

**Description.** This finding was found during our audit by the client, we include it in the report for completeness.

The `tallyVotes()` method validates that the vote reducer used the correct _output_ voting ledger root from the staking-to-voting ledger transformation, and that the transformation started from the correct staking ledger. However, it does not constrain the _initial_ voting ledger root that the transformation started from:

```typescript
// Checked: vote reducer used the transformation's output voting ledger
voteReducerPublicInput.votingLedgerRoot
  .equals(stakingLedgerToVotingLedgerPublicOutput.votingLedgerRoot)
  .assertTrue("voting ledger root does not match");

// Checked: transformation used the correct staking ledger
stakingLedgerToVotingLedgerPublicInput.stakingLedgerRoot
  .equals(stakingEpochDataLedgerHash)
  .assertTrue("staking ledger root does not match");

// [ZKSECURITY] NOT checked: stakingLedgerToVotingLedgerPublicInput.votingLedgerRoot
// (the initial voting ledger root is completely unconstrained)
```

The `emptyVotingLedgerRoot` was clearly intended for this check but is never referenced anywhere in the codebase. By contrast, the equivalent check for the nullifier root _is_ correctly implemented:

```typescript
voteReducerPublicInput.fromNullifierRoot
  .equals(TreasuryProposalSmartContract.emptyNullifierRoot)
  .assertTrue("fromNullifierRoot does not match");
```

**Impact.** An attacker can pre-populate a voting ledger giving themselves an arbitrarily large balance, then run the staking-to-voting transformation starting from this malicious ledger instead of an empty one. The transformation adds the real staking balances on top of the already-inflated values. The resulting voting ledger is used by the vote reducer, so the attacker's vote carries massively inflated weight and they can single-handedly approve any proposal.

**Recommendation.** Add the missing check in `tallyVotes()`:

```typescript
stakingLedgerToVotingLedgerPublicInput.votingLedgerRoot
  .equals(TreasuryProposalSmartContract.emptyVotingLedgerRoot)
  .assertTrue("initial voting ledger root must be empty");
```

**Client response.** Acknowledged and fixed in commit [397c77120544fb460d36350cbe06773419b0c0e1](https://github.com/maht0rz/decentralised-treasury/commit/397c77120544fb460d36350cbe06773419b0c0e1).

### Missing Token ID Check on Treasury Owner Allows Acceptance Criteria Manipulation

- **Severity**: Medium
- **Location**: contracts/treasury-proposal/treasury-proposal.ts

<!-- ZKS ISSUE MEDIUM treasury-proposal.ts:243
  Token ID not checked on treasury owner account, so a different token account
  with the same public key can be used to manipulate acceptance criteria.
-->

**Description.** The `tallyVotes()` method uses the treasury owner's balance from the staking ledger to calculate dynamic acceptance criteria (participation and approval thresholds). The code verifies that the provided account's public key matches the treasury owner's address and proves the account's inclusion in the staking ledger via a Merkle witness, but it does not check the account's `tokenId`:

```typescript
// [ZKSECURITY] public key is checked, tokenId is not
treasuryOwnerAccount.pk
  .equals(treasuryOwnerPublicKey)
  .assertTrue("Treasury owner account public key does not match");
```

In Mina, accounts are uniquely identified by the pair `(publicKey, tokenId)`. In particular, the same public key can have multiple accounts in the staking ledger, i.e., one for the MINA token and additional ones for custom tokens, each with a different balance.

**Impact.** If the treasury owner address has a custom token account in the staking ledger with a higher balance than its MINA account, a prover can supply that account instead. Since `calculateAcceptanceCriteria()` computes thresholds based on `proposalAmount / treasuryBalance`, a larger balance produces a smaller ratio and therefore lower participation and approval thresholds. This makes it easier than intended to approve proposals.

Conversely, providing an account with a smaller balance inflates the ratio and raises thresholds, which could be used to grief legitimate proposals by making them harder to approve.

In particular, these attacks are clearly available to the treasury owner private key holder, who can deploy custom token accounts for this address themselves.

**Recommendation.** Add a corresponding token ID check alongside the existing public key check.

**Client response.** Acknowledged and fixed in commit [3a8eb54a64dd4a58311048c22a63bfefa67c5b3b](https://github.com/maht0rz/decentralised-treasury/commit/3a8eb54a64dd4a58311048c22a63bfefa67c5b3b).

### Restrictive Treasury Owner Permissions Prevent Contract Operation

- **Severity**: Medium
- **Location**: contracts/treasury-owner.ts

<!-- ZKS ISSUE MEDIUM treasury-owner.ts — permissions set send/receive to impossible,
  breaking core treasury functionality. (Separate from the init() ineffectiveness issue.)
-->

**Description.** The treasury owner's permissions spread `Permissions.allImpossible()` and only override `editState`, `access`, `incrementNonce`, and `setVerificationKey`:

```tsx
public static permissions = {
  ...Permissions.allImpossible(),
  editState: Permissions.proof(),
  access: Permissions.proof(),
  incrementNonce: Permissions.proof(),
  setVerificationKey: Permissions.VerificationKey.impossibleDuringCurrentVersion(),
};
```

`Permissions.allImpossible()` sets all permissions to `impossible`, including `send` and `receive`. Since neither is overridden, the contract cannot:

- receive funds via `this.self.balance.addInPlace(bondAmount)` in `createProposal`
- send funds via `this.self.balance.subInPlace(amountToPayOut)` in `executeProposal`

**Impact.** If deployed with these permissions, the treasury contract cannot accept bond payments and cannot execute approved proposals so that the core treasury functionality is broken. Additionally, since `setPermissions` is also `impossible`, the permissions cannot be corrected after deployment.

This issue would have been clearly caught in testing; the reason it went unnoticed is that the permissions are [never actually applied](#finding-high-init-permissions-ineffective).

**Recommendation.** Override `send` and `receive` permissions:

```tsx
public static permissions = {
  ...Permissions.allImpossible(),
  editState: Permissions.proof(),
  access: Permissions.proof(),
  send: Permissions.proof(),
  receive: Permissions.proof(),
  incrementNonce: Permissions.proof(),
  setVerificationKey: Permissions.VerificationKey.impossibleDuringCurrentVersion(),
};
```

**Client response.** Acknowledged and fixed in commit [791401edf87e47d901ed4c76323fbfbf44b56c4a](https://github.com/maht0rz/decentralised-treasury/commit/791401edf87e47d901ed4c76323fbfbf44b56c4a).

### Anyone Can Execute an Approved Proposal on Behalf of the Recipient

- **Severity**: Low
- **Location**: contracts/treasury-owner.ts

**Description.** The `executeProposal()` method can be called by anyone, not just the proposal's recipient. The caller controls both the timing and, because the contract supports partial payouts, the amount transferred in each execution call:

```ts
public async executeProposal(
  proposalPublicKey: PublicKey,
  recipient: PublicKey,
  amountToPayOut: UInt64,
) {
  // ...
  this.self.balance.subInPlace(amountToPayOut);
  await proposal.execute(amountToPayOut, recipient);
}
```

While the proposal contract verifies that the `recipient` matches the on-chain `recipientHash` and that `amountToPayOut` does not exceed the remaining amount, there is no restriction on who initiates the call.

**Impact.** A third party can force payouts to the recipient at a time and in amounts the recipient did not choose. For example, an attacker could split the payout into many small partial executions, which could be inconvenient or costly for the recipient depending on how they handle incoming funds. More broadly, the recipient has no ability to delay or decline execution once a proposal is approved.

**Recommendation.** Restrict `executeProposal()` so that only the recipient can trigger it, by requiring a signature from the recipient's key.

**Client response.** Acknowledged.

### Bond Is Paid to the Proposal Recipient Rather Than Returned to the Proposer

- **Severity**: Low
- **Location**: contracts/treasury-proposal/treasury-proposal.ts

**Description.** When a proposal is created, the proposer deposits a bond equal to `amount / BOND_AMOUNT_DIVISOR` (i.e., 10% of the proposal amount) into the treasury:

```tsx
// in treasury-owner.ts
const bondAmount = proposal.amount.div(BOND_AMOUNT_DIVISOR);
this.self.balance.addInPlace(bondAmount);
```

When the proposal is executed, the recipient receives
`amount + amount/10`:

```tsx
// in treasury-proposal.ts
const amountWithBond = amount.add(amount.div(BOND_AMOUNT_DIVISOR));
const remainingAmount = amountWithBond.sub(paidOutAmount);
```

In other words, the bond flows from proposer to treasury to recipient.

**Impact.** If the proposer is different from the recipient, the proposer loses their bond even when the proposal succeeds, while the recipient receives 10% more than the stated proposal amount.

**Recommendation.** Track the proposer's address on-chain and return the bond to the proposer upon successful execution, rather than sending it to the recipient. Alternatively, enforce that recipient and proposer are always the same entity.

**Client response.** Acknowledged.

### Weak Signature Binding in Pause Controller Multisig

- **Severity**: Low
- **Location**: contracts/treasury-pause-controller/treasury-pause-controller.ts

<!-- ZKS ISSUE LOW-MEDIUM treasury-pause-controller.ts:162
  Signatures don't include network id or contract address. One participant could reuse
  signatures from others across controllers/environments. Mitigated somewhat by signatures
  being private inputs.
-->

**Description.** The multisig signed payloads for pause/unpause/toggle operations include an action-specific prefix and a nonce, but do not include the network ID or the pause controller's contract address. For example, `dataPauseTreasury()` hashes only the prefix and nonce:

```js
public static dataPauseTreasury(nonce: UInt32) {
  return hashWithPrefix(this.prefixPauseTreasury, [...nonce.toFields()]);
}
```

If the same signer set and nonce are used across multiple controller instances (e.g., testnet and mainnet, or two treasuries), the signatures are interchangeable. Since signatures are private inputs to the circuit, a single multisig participant who holds valid signatures from the others for one controller could replay them against a different controller without the other participants' knowledge.

**Impact.** A single multisig participant could unilaterally pause or unpause a different controller instance by replaying signatures collected for another instance, bypassing the intended threshold requirement. The severity is mitigated by the fact that signatures are private inputs (an external observer cannot extract them from on-chain data), so the attack realistically requires being a multisig participant.

**Recommendation.** Include the network ID and the controller's contract address in all four signed payloads: `dataPauseTreasury()`, `dataUnpauseTreasury()`, `dataTogglePauseProposal()`, and `dataRotateMultisigKeys()`. The network ID can be made a circuit constant specific to the deployment.

**Client response.** Acknowledged.

### Slot Precondition Uses Last Block Slot Instead of Inclusion Slot

- **Severity**: Low
- **Location**: contracts/treasury-owner.ts

<!-- ZKS ISSUE MEDIUM treasury-owner.ts:179
  Uses this.network.globalSlotSinceGenesis (last published block) instead of this.currentSlot
  (actual inclusion slot). The precondition may not reflect the true time the tx is included.
-->

**Description.** The `requireLifecyclePeriod()` method enforces that governance operations (proposing, voting, tallying, executing) happen within their designated time windows. It does so using `this.network.globalSlotSinceGenesis`:

```js
this.network.globalSlotSinceGenesis.requireBetween(fromSlot, toSlot);
```

However, `this.network.globalSlotSinceGenesis` is checked by the Mina node against the network state at the start of the current block, that is, the slot of the previous block. It does not reflect the slot of the block the transaction is actually included in. The correct API is `this.currentSlot`, which constrains the actual inclusion slot:

```js
this.currentSlot.requireBetween(fromSlot, toSlot);
```

**Impact.** Since the precondition checks the previous block's slot rather than the current one, there is a one-block discrepancy. A transaction included in the first block of a new lifecycle period will still pass the precondition for the previous period. This allows, for example, casting a vote one block after the voting period has ended.

**Recommendation.** Replace `this.network.globalSlotSinceGenesis.requireBetween(...)` with `this.currentSlot.requireBetween(...)`.

**Client response.** Acknowledged.

### `commitActionState()` and `tallyVotes()` Could Be Merged to Reduce Attack Surface

- **Severity**: Informational
- **Location**: contracts/treasury-owner.ts

<!-- ZKS ISSUE INFO treasury-owner.ts:396
  commitActionState is a separate method from tallyVotes, adding complexity and attack surface
  (e.g., calling commit during ongoing tally). Merging them would simplify the flow and improve UX,
  with careful implementation to stay within account update limits.
-->

**Description.** The vote-tallying flow is split across two separate `@method` calls on `TreasuryOwner`: `commitActionState()` and `tallyVotes()`. The first method creates five account updates (AUs) with `none` authorization on the proposal account via `AccountUpdate.create()` to validate the action-state history, then calls `proposal.commitActionState()` to write the committed action hash. The second method verifies the vote-reducer and staking-ledger proofs and calls `proposal.tallyVotes()` to record the final vote outcome. Because they are independent transactions, a caller must submit two separate operations in sequence.

The split was originally motivated by account-update cost-limit errors during development. Mina enforces a per-transaction [cost limit](https://github.com/o1-labs/o1js/blob/f8f5fe4735ab2d63dee8db07178075855e11c71a/src/lib/mina/v1/constants.ts#L7-L19) on account updates: each `proof`-authorized AU costs `PROOF_COST = 10.26`, non-`proof` AUs (`signature` or `none`) are batched into pairs at `SIGNED_PAIR_COST = 10.08` each (with `SIGNED_SINGLE_COST = 9.14` for an unpaired remainder), and the total must stay below `COST_LIMIT = 69.45`. However, as shown below, a combined method fits comfortably within this limit once unnecessary proof authorizations are removed and proposal-account updates are consolidated.

Splitting the logic across two methods made it significantly harder to maintain end-to-end consistency between the action-state validation and the tally. Our finding [Action state history not linked to actions hash](#finding-high-action-state-history-not-linked-to-actions-hash) is a direct consequence: because `commitActionState()` and `tallyVotes()` are separate transactions, there is no atomic link between the action-state validation and the tally. The separation also means `commitActionState()` could be called during an ongoing tally, or the two steps could be interleaved with other proposal operations in unexpected ways.

**Recommendation.** Merge `commitActionState()` and `tallyVotes()` into a single `@method`. Currently, the two methods combined create several `proof`-authorized AUs that do not need proof authorization.

As described in [Proposal methods do not need proofs](#finding-info-proposal-methods-dont-need-proofs), the proposal's `@method` calls (`proposal.commitActionState()` and `proposal.tallyVotes()`) can be replaced by `none`-authorized AUs, since the token owner mechanism already forces us to go through `TreasuryOwner` methods to modify any proposal account.

The tally logic currently inside `proposal.tallyVotes()` (acceptance-criteria calculation, proof-input validation, state reads) would move into the `TreasuryOwner` method, while the proposal AU itself would only carry the state updates (`status`). Additionally, the `toActionsHash` state field, which only exists to bridge the two methods, can be removed entirely, freeing up one of the proposal's eight on-chain state slots.

Similarly, the `requireNotPaused()` call into `TreasuryPauseController` is currently `proof`-authorized but only checks a precondition, and can be refactored to use `none`.

With these changes, the merged method would need:

- 1 `proof`-authorized AU for the `TreasuryOwner` method itself,
- 1 `none`-authorized AU for `requireNotPaused()`,
- 5 `none`-authorized AUs on the proposal account for the action-state history preconditions, one of which also carries the proposal state updates,
- 1 fee payer.

That totals `1×PROOF_COST + 3×SIGNED_PAIR_COST + 1×SIGNED_SINGLE_COST = 49.64`, well below the cost limit, with room for 1–2 additional account updates if needed.

**Client response.** Acknowledged and fixed in commit [791401edf87e47d901ed4c76323fbfbf44b56c4a](https://github.com/maht0rz/decentralised-treasury/commit/791401edf87e47d901ed4c76323fbfbf44b56c4a).

### `createProposal()` Relies Solely on Permissions to Prevent Proposal State Reset

- **Severity**: Informational
- **Location**: contracts/treasury-owner.ts

<!-- ZKS ISSUE INFO treasury-owner.ts:272
  createProposal can be called again with the same public key to reset proposal state.
  Only permissions prevent this. An isNew precondition would be more robust and explicit.
-->

**Description.** `createProposal()` is not explicitly preventing itself from being called twice with the same `proposalPublicKey`. A second call would overwrite the proposal's entire state, including resetting `proposal.amount` to a new, caller-determined value, which on an already-approved proposal could allow extracting funds that were never voted on.

Currently, calling it twice is prevented indirectly by permissions: `createProposal()` sets `Permissions.default()` on the proposal account, which includes `editState: Permission.proof()`. Since the account update created by `createProposal()` is only signature-authorized (via `AccountUpdate.createSigned()`), the protocol would reject state edits on a second call. However, this protection is implicit and would break if permissions were ever relaxed (e.g., as part of adopting the changes described in [Proposal methods do not need proofs](#finding-info-proposal-methods-dont-need-proofs)).

**Recommendation.** Add an `isNew` account precondition to the proposal account update, explicitly requiring the account to not yet exist:

```ts
proposalUpdate.account.isNew.requireEquals(Bool(true));
```

This makes the single-use intent explicit and robust against future permission changes.

**Client response.** Acknowledged.

### Delegate Emptiness Check Used Instead of Token ID to Identify MINA Accounts

- **Severity**: Informational
- **Location**: staking-ledger-to-voting-ledger.ts

<!-- ZKS ISSUE INFO staking-ledger-to-voting-ledger.ts:261
  Uses empty delegate check to determine if account is a MINA account instead of
  checking tokenId == TokenId.default. The tokenId check would be more explicit and future-proof.
  Currently equivalent due to mina tx logic invariants.
-->

**Description.** The `digest` method in `StakingLedgerToVotingLedger` iterates over all accounts in the staking ledger and accumulates each account's balance into a voting-ledger entry keyed by the account's `delegate` address. The staking ledger contains both MINA accounts and token accounts. For MINA accounts, the `delegate` field contains either the account's own address (self-delegation) or a real delegate address. For token accounts, the `delegate` field is always the "empty" public key, an invariant that is enforced by the Mina protocol's transaction logic.

The code relies on this convention implicitly: token accounts' balances are accumulated under the empty delegate address in the voting ledger. This has no effect on governance outcomes because the `vote()` method requires a signature from the voter (`AccountUpdate.createSigned(publicKey)`), and nobody holds the private key for the empty public key. However, the `Account` struct already includes a `tokenId` field, and directly checking it would make the intent explicit.

**Recommendation.** Use the `tokenId` check to explicitly identify MINA accounts and skip the voting-ledger update for token accounts:

```ts
const isMinaAccount = account.tokenId.equals(TokenId.default);
```

An easy approach is to conditionally zero out the balance addition, which is a one-line change:

```ts
const balanceToAdd = Provable.if(
  isMinaAccount,
  account.balance,
  UInt64.from(0),
);
votingAccount.balance = votingAccount.balance.add(balanceToAdd);
```

This keeps the circuit structure unchanged (adding zero makes the root update a no-op). The voting ledger is a height-256 Merkle tree where every index is initialized with an empty (zero-balance) voting account, so the witness lookup and root inclusion check pass naturally for the empty delegate entry without any additional changes.

**Client response.** Acknowledged.

### `DUMMY` Vote Accepted as Valid, Allowing Voters to Burn Their Vote Without Contributing to Participation

- **Severity**: Informational
- **Location**: contracts/treasury-proposal/vote-reducer.ts

<!-- ZKS ISSUE INFO treasury-owner.ts:326
  You can vote DUMMY (with no effect on either vote outcome or participation).
-->

**Description.** `TreasuryOwner.vote()` allows users to submit a `DUMMY` (0) vote. The only validation is a call to `Vote.assertValid()`, which accepts `DUMMY` alongside `YAY` (1), `NAY` (2), and `ABSTAIN` (3).

```typescript
// in TreasuryOwner.vote()
Vote.assertValid(vote); // [ZKSECURITY] accepts DUMMY
```

`DUMMY` is intended as internal padding for fixed-size batches in the vote reducer. The reducer identifies padding via `VoteAction.isDummy()`, which checks both `vote == DUMMY` _and_ `publicKey == PublicKey.empty()`. When a real user submits a `DUMMY` vote, their public key is not empty, so `isDummy()` returns false and the vote reducer treats it as a real action:

- The voter's nullifier is set, i.e., they cannot vote again on the given proposal.
- The action is appended to the action hash chain.
- But the vote value (0) does not match `YAY`, `NAY`, or `ABSTAIN`, so the vote is _not_ added to the participation count.

By contrast, an `ABSTAIN` vote also leaves `YAY`/`NAY` unchanged but _does_ contribute the voter's balance to `totalParticipatingVotes = yay + nay + abstain`, which is checked against the participation threshold.

**Impact.** Voters can accidentally or intentionally "abstain" without their vote being included in `totalParticipatingVotes`. In the extreme case, this could cause a proposal to fail the participation threshold even though enough people voted.

**Recommendation.** Reject `DUMMY` votes in `TreasuryOwner.vote()` before dispatching the action. Note that `Vote.assertValid()` should be left unchanged as it is also called in `reduceBatch()`, where dummy padding must remain accepted.

```diff
// in TreasuryOwner.vote()
+ vote.assertNotEquals(Vote.DUMMY, "DUMMY vote not allowed");
Vote.assertValid(vote);
```

**Client response.** Acknowledged.

### Struct Comparison via Hashing Is Inefficient

- **Severity**: Informational
- **Location**: staking-ledger-to-voting-ledger.ts

<!-- ZKS ISSUE INFO staking-ledger-to-voting-ledger.ts:107
  Uses Poseidon.hash on both sides to compare structs instead of the more efficient
  Provable.assertEqual which compares field-by-field without hashing overhead.
-->

**Description.** Several ZkProgram methods compare struct equality by hashing both sides with `Poseidon.hash()` and comparing the digests, rather than comparing the struct fields directly. For example, in `StakingLedgerToVotingLedger.exhaust()`:

```ts
Poseidon.hash(
  StakingLedgerToVotingLedgerProgramInput.toFields(publicInput),
).assertEquals(
  Poseidon.hash(StakingLedgerToVotingLedgerProgramInput.toFields(input)),
);
```

`StakingLedgerToVotingLedgerProgramInput` contains only three fields (`index`, `stakingLedgerRoot`, `votingLedgerRoot`), so this computes two in-circuit Poseidon hashes just to assert equality of three field elements. The same pattern appears throughout the codebase: in the `merge` methods of both `StakingLedgerToVotingLedger` and `VoteReducer`, as well as in `Account.isEmpty()` and `VotingAccount.isEmpty()`.

**Recommendation.** Replace with `Provable.assertEqual()`, which compares fields element-by-element. In PLONK (used by Mina), field equality is enforced via the permutation argument and typically requires zero additional constraints:

```ts
Provable.assertEqual(
  StakingLedgerToVotingLedgerProgramInput,
  publicInput,
  input,
);
```

**Client response.** Acknowledged.

### No Minimum Proposal Amount or Voting Balance Enforced

- **Severity**: Informational
- **Location**: contracts/treasury-owner.ts

<!-- ZKS ISSUE LOW treasury-owner.ts:203
  Easy to spam because no lower bound on bond amount.
-->

**Description.** The bond mechanism is designed to ensure proposers have skin in the game proportional to the amount requested. This prevents "qualitative" spam, i.e., it prevents proposers from spamming the treasury with requests for large amounts.

However, there is no safeguard to prevent users from spamming "dust" proposals, i.e., a proposer can create a proposal with `amount = 1`, making the bond zero (`1 / BOND_AMOUNT_DIVISOR = 1 / 10 = 0` in `UInt64` integer division).

Casting votes currently comes with a similar behavior: any account can submit votes, regardless of how small its delegated balance in the voting ledger is.

**Impact.**

- **Proposals:** Users can spam the system with dust proposals at low cost (only transaction fees and the account creation fee per proposal). In principle, anyone can try to DDoS the chain itself with their own zkApp, so the risk of this kind of "quantitative" spam is not treasury-specific. However, spamming dust proposals comes with the additional drawback that it could obscure legitimate proposals and degrade UX.
- **Votes:** Without a minimum voting balance, vote actions can be dispatched by negligible-weight accounts, inflating the action list. This increases the cost of vote reduction without contributing meaningful governance signal.

**Recommendation.** Introduce a `MIN_AMOUNT` constant for proposals and a `MIN_VOTING_BALANCE` constant for voters, and require the proposal amount and the voter's voting balance to be greater than the respective constant.

**Client response.** Acknowledged.

### Staking-to-Voting Ledger Proof Could Be Significantly More Efficient

- **Severity**: Informational
- **Location**: staking-ledger-to-voting-ledger.ts

<!-- ZKS ISSUE INFO staking-ledger-to-voting-ledger.ts:76
  Could use IndexedMerkleMap instead of height-255 MerkleTree for cheaper non-inclusion proofs,
  and process only the delta of changed accounts instead of rebuilding the full ledger.
-->

The `StakingLedgerToVotingLedger` ZkProgram rebuilds the voting ledger from scratch by iterating over every account in the staking ledger (in batches of 5) and accumulating balances into a height-256 Merkle tree. We see two independent opportunities to significantly reduce the proving cost:

1. **Use `IndexedMerkleMap` for the voting ledger.** The voting ledger currently uses a plain Merkle tree of height 256, keyed by `Poseidon.hash(publicKey.toFields())`, because a plain Merkle tree cannot prove non-inclusion and must therefore cover the full key space. Each Merkle witness contains 256 sibling hashes. o1js provides `IndexedMerkleMap`, which supports both inclusion and non-inclusion proofs in a tree whose height only needs to accommodate the actual number of entries. Using `IndexedMerkleMap` with a height of ~20 would reduce per-account proving cost by roughly **5x**. Likely, the `IndexedMerkleMap` would need adaptation for the parallel proving workflow used here (where witnesses are pre-traced sequentially before proving in parallel), but this should be straightforward.

2. **Process the account delta instead of the full ledger.** Rather than re-processing every account in the staking ledger each epoch, the program could process only the _account delta_: the list of accounts that changed since the last staking epoch snapshot. The circuit would prove the delta is complete by applying each change to the staking ledger and recovering the correct ledger hash, while simultaneously updating an existing voting ledger with each changed account. The cost reduction depends on what fraction of accounts change per epoch. According to a few sample epochs we investigated, this fraction is currently around 10-15%. The per-account circuit cost is slightly higher (the staking ledger needs to be updated, not just read), but the reduction in total accounts processed dominates: we expect at least a **6x** improvement over full-ledger processing.

These improvements are independent and their cost reductions are multiplicative, yielding a combined reduction of **~30x**.

**Client response.** Acknowledged.

### Proposal Contract Methods Do Not Need Proofs

- **Severity**: Informational
- **Location**: contracts/treasury-proposal/treasury-proposal.ts

<!-- ZKS ISSUE INFO treasury-proposal.ts:60
  All @methods on TreasuryProposalSmartContract are only callable through the treasury owner.
  They don't need proof authorization — could be account updates without proofs.
  Would improve simplicity, reduce security analysis surface, and speed up transaction creation.
  Suggested adding a @methodWithoutProof decorator for this pattern.
-->

**Description.** `TreasuryProposalSmartContract` defines six `@method` calls, each requiring a separate proof to be generated and verified on-chain:

- `getLifecycleId()` returns the proposal's lifecycle ID,
- `vote()` dispatches a vote action,
- `commitActionState()` sets `toActionsHash`,
- `tallyVotes()` validates proofs, computes acceptance criteria, sets `status`,
- `execute()` handles payout logic,
- `togglePause()` toggles the proposal's paused state.

However, these methods are only callable through `TreasuryOwner` since the proposal is a token account (child of the `TreasuryOwner` `TokenContract`), and the treasury owner's `approveBase()` rejects all external account updates:

```ts
public async approveBase(updates: AccountUpdateForest) {
  this.forEachUpdate(updates, (update, usesToken) => {
    usesToken.assertFalse(
      "No external account updates allowed for this token",
    );
  });
}
```

This means the only way to interact with the proposal account is through one of `TreasuryOwner`'s own `@method` calls, which create and `this.approve()` child account updates (AUs). In Mina, there are two independent mechanisms for constraining what can happen to an account:

- **Proof authorization** forces callers to invoke one of the contract's `@method` entries.
- **Token ownership** forces callers to go through the token owner contract to interact with the child account.

The proposal currently uses both. The token ownership mechanism is the one that matters here, as it prevents proposal methods from being called in isolation. Once that is in place, proof authorization on the proposal's own methods is redundant: the treasury owner's proof already constrains which child AUs are created and approved.

Removing the proposal's `@method` decorators would reduce the number of `proof`-authorized AUs per transaction, speed up transaction creation by eliminating proof generation for these calls, and avoid running against circuit/method size limits on the proposal contract. This is directly relevant to [`commitActionState` and `tallyVotes` could be merged](#finding-info-commit-and-tally-could-be-merged), where the AU budget is tight precisely because the proposal's methods currently require proof authorization.

As a proof of concept, we demonstrate the approach for `getLifecycleId()` — originally a `@method.returns(UInt32)` described as a "workaround since reading state from another contract resulted in proving errors." The replacement reads the proposal's state and directly enforces it via a `none`-authorized AU with a state precondition:

```ts
getProposalLifecycleId(proposalPublicKey: PublicKey) {
  const proposal = new TreasuryProposalSmartContract(
    proposalPublicKey,
    this.deriveTokenId(),
  );
  let proposalLifecycleId = proposal.lifecycleId.get();
  proposal.lifecycleId.requireNothing();

  // [ZKSECURITY] creates a none-authorized AU with the lifecycleId as a precondition,
  // replacing the proof-authorized @method.returns(UInt32)
  const proposalUpdate = AccountUpdate.create(
    proposalPublicKey,
    this.deriveTokenId(),
  );
  proposalUpdate.body.preconditions.account.state[2] = Option(Field).from(
    proposalLifecycleId.value,
  );
  return proposalLifecycleId;
}
```

This approach works but is awkward: it requires instantiating `TreasuryProposalSmartContract` just to read the `lifecycleId` state conveniently, then immediately discarding its precondition (`requireNothing()`) and manually constructing a separate AU with the precondition set on the body. This loses the conveniences that `@method` provides.

**Recommendation.** Remove the `@method` decorators from `TreasuryProposalSmartContract` and replace the current `proof`-authorized calls with `none`-authorized AUs created and approved by the treasury owner. The proposal's `editState` permission should be relaxed from `Permission.proof()` to `Permission.none()` to allow state modifications from `none`-authorized AUs. This is safe because `approveBase()` already prevents any external account from touching the proposal — only AUs explicitly approved within treasury owner methods can reach it.

Since o1js does not currently provide a `@methodWithoutProof` decorator that preserves the ergonomics of `@method` (automatic state management, preconditions) while skipping proof generation, we recommend to create such an abstraction either in the already maintained fork of o1js (upstreaming it as soon as possible) or external to o1js, which should be possible.

Two words of caution when making this change:

- We recommend to evaluate the possibility that a `none` AU might _create_ instead of update an account, bypassing `createProposal()` (for a `proof` AU, this is impossible). An `isNew(false)` precondition on the AU can rule that out in case of doubt.
- Relaxing preconditions, in particular `editState`, means that `createProposal()` would be [no longer protected from being called twice](#finding-info-create-proposal-missing-is-new-precondition). That issue (currently marked informational) becomes critical and _must_ be resolved when switching to non-proof proposals.

**Client response.** Acknowledged.

### Proposal Status Uses Implicit `Field(0)` Instead of Explicit `ProposalStatus.UNKNOWN`

- **Severity**: Informational
- **Location**: contracts/treasury-owner.ts

<!-- ZKS ISSUE INFO treasury-owner.ts:242
  createProposal sets status as Field(0) rather than ProposalStatus.UNKNOWN.
  Using the enum value (or even Option types) would be more explicit and safer against
  accidental reordering of enum variants.
-->

In `createProposal()`, the proposal's status field is initialized using a raw `Field(0)` literal instead of the named constant `ProposalStatus.UNKNOWN`:

```ts
{
  isSome: Bool(true),
  value: Field(0), // [ZKSECURITY] should be ProposalStatus.UNKNOWN
},
```

Since `ProposalStatus` extends `Field`, the named constant `ProposalStatus.UNKNOWN` could be used directly here. That would improve readability and robustness against accidental reordering of enum variants.

Note that the status field is particularly sensitive: setting it to `Field(1)` (`ProposalStatus.APPROVED`) would allow immediate fund withdrawal from the treasury.

More broadly, adopting the `@methodWithoutProof` decorator proposed in [Proposal methods do not need proofs](#finding-info-proposal-methods-dont-need-proofs) would allow using the proposal contract's named `@state` fields directly, eliminating the need for raw `appState` array manipulation.

**Client response.** Acknowledged.

### Redundant `fromActionsHash` Assertion in `tallyVotes`

- **Severity**: Informational
- **Location**: contracts/treasury-proposal/treasury-proposal.ts

**Description.** In `tallyVotes()`, the following assertion appears twice:

```typescript
// First occurrence
voteReducerPublicInput.fromActionsHash
  .equals(Reducer.initialActionState)
  .assertTrue("fromActionsHash should be the initial action state");

voteReducerPublicInput.fromNullifierRoot
  .equals(TreasuryProposalSmartContract.emptyNullifierRoot)
  .assertTrue("fromNullifierRoot does not match");

// Second occurrence
// check that vote reducer proof started tallying actions from the initial action state
voteReducerPublicInput.fromActionsHash
  .equals(Reducer.initialActionState)
  .assertTrue("fromActionsHash should be the initial action state");
```

The two assertions are identical so that the second instance is redundant.

**Impact.** No security impact.

**Recommendation.** Remove one of the two identical assertions.

**Client response.** Acknowledged.

### Staking Epoch Data Lags Behind by One Epoch; `nextEpochData` Would Improve UX

- **Severity**: Informational
- **Location**: contracts/treasury-owner.ts

<!-- ZKS ISSUE INFO treasury-owner.ts:92
  snapshotStakingEpochData uses stakingEpochData which lags behind an entire epoch.
  Using nextEpochData would provide the most recent available ledger and still be stable during the epoch.
-->

`snapshotStakingEpochData()` uses `this.network.stakingEpochData` to capture the ledger hash and total currency for a proposal's voting weights:

```ts
const networkStakingEpochDataLedgerHash =
  this.network.stakingEpochData.ledger.hash.getAndRequireEquals();
const networkStakingEpochDataLedgerTotalCurrency =
  this.network.stakingEpochData.ledger.totalCurrency.getAndRequireEquals();
```

In Mina, `stakingEpochData` refers to the ledger from two epochs ago. The one-epoch delay between finalizing a ledger and using it as the staking snapshot exists for the consensus algorithm (staking ledger needs to be fully distributed to block producers before it can be activated), but this requirement does not apply to proposal creation. Using the most recent finalized ledger is simply better UX.

The more recent `nextEpochData` refers to the ledger from just before the current epoch started. It is equally stable during the epoch (it does not change within an epoch), but reflects more recent account balances and delegation changes. It is available as a precondition via `this.network.nextEpochData.ledger.hash` and is already supported by the ledger exporter.

Since the snapshot is taken during the PROPOSAL period but voting happens two lifecycle periods later, the effective staleness by the time votes are cast is roughly 4 epochs with `stakingEpochData` versus 3 with `nextEpochData`.

**Client response.** Acknowledged.

### `togglePause()` Does Not Take the Intended Pause State as Input

- **Severity**: Informational
- **Location**: contracts/treasury-proposal/treasury-proposal.ts

<!-- ZKS ISSUE INFO-LOW treasury-proposal.ts:346
  togglePause toggles blindly rather than taking the intended state as a parameter,
  making the operation less explicit and potentially surprising in concurrent scenarios.
-->

`togglePause()` on the proposal contract blindly flips the pause state rather than taking the intended state as a parameter:

```ts
@method
public async togglePause() {
  const status = this.status.getAndRequireEquals();
  const newStatus = Provable.if(
    status.equals(ProposalStatus.PAUSED),
    ProposalStatus.UNKNOWN,
    ProposalStatus.PAUSED,
  );
  this.status.set(newStatus);
}
```

This means the caller (the multisig via `TreasuryOwner.togglePauseProposal()`) cannot express whether it intends to pause or unpause — it can only toggle. If the on-chain state changes between transaction creation and inclusion (e.g., another `togglePause()` transaction lands first), the operation may produce the opposite of the intended effect.

Taking the intended pause state as a parameter, and asserting the current state is the opposite, would make the operation explicit and safe against concurrent submissions. When making this change, we recommend to also pass the direction as signature input to make sure each signer's signature captures their intent.

**Client response.** Acknowledged.

### `togglePause()` Resets Proposal Finality, Requiring Retally After Unpause

- **Severity**: Informational
- **Location**: contracts/treasury-proposal/treasury-proposal.ts

<!-- ZKS ISSUE LOW treasury-proposal.ts:352
  Proposal gets reset to UNKNOWN when unpaused, independent of actual status.
  Could pack two separate states (status + paused) into 1 field to avoid this.
-->

**Description.** The `togglePause()` method overloads the `ProposalStatus` field to track both the voting outcome (`APPROVED`/`REJECTED`) and the pause state (`PAUSED`). When a proposal is paused, its status is unconditionally set to `PAUSED`, destroying the previous value. When unpaused, the status is reset to `UNKNOWN` rather than the original outcome:

```typescript
const newStatus = Provable.if(
  status.equals(ProposalStatus.PAUSED),
  ProposalStatus.UNKNOWN, // always resets to UNKNOWN, not the pre-pause value
  ProposalStatus.PAUSED,
);
```

This means that after a pause/unpause cycle on a finalized proposal, the tally must be resubmitted using the precomputed proofs to restore the `APPROVED` or `REJECTED` status.

The lifecycle period assertions (`requireLifecyclePeriodGreaterThanOrEqual`) do permit this retally. The result is identical since the on-chain votes and staking ledger snapshot are unchanged, so there is no integrity risk, only unnecessary operational burden.

**Recommendation.** Add a dedicated `@state(Bool) isPaused` to the proposal contract rather than overloading `ProposalStatus`. This cleanly separates the pause mechanism from the voting outcome and eliminates the need to retally after unpause.

The proposal contract currently uses all 8 state slots, but a slot becomes available if `toActionsHash` is removed as described in [`commitActionState` and `tallyVotes` could be merged](#finding-info-commit-and-tally-could-be-merged).

**Client response.** Acknowledged.

### Unnecessary Proof Verification When Only Public Inputs Are Needed

- **Severity**: Informational
- **Location**: contracts/treasury-owner.ts

<!-- ZKS ISSUE INFO treasury-owner.ts:358
  tallyVotes verifies both voteReducerProof and stakingLedgerToVotingLedgerProof at the top level,
  but if only public inputs are passed to the nested call, this verification effort is redundant.
-->

`TreasuryOwner.tallyVotes()` verifies both the vote-reducer and staking-ledger-to-voting-ledger proofs, then passes the full proof objects to `proposal.tallyVotes()`, which verifies them again:

```ts
// in TreasuryOwner.tallyVotes()
voteReducerProof.verify(
  TreasuryProposalSmartContract.voteReducerVerificationKey,
);
stakingLedgerToVotingLedgerProof.verify(
  TreasuryProposalSmartContract.stakingLedgerToVotingLedgerVerificationKey,
);
// ...
await proposal.tallyVotes(
  voteReducerProof, // [ZKSECURITY] full proof passed
  stakingLedgerToVotingLedgerProof, // [ZKSECURITY] full proof passed
  // ...
);
```

```ts
// in TreasuryProposalSmartContract.tallyVotes()
voteReducerProof.verify(/* ... */); // [ZKSECURITY] verified again
stakingLedgerToVotingLedgerProof.verify(/* ... */); // [ZKSECURITY] verified again
```

This results in four proof verifications total (two per circuit), where two would suffice. Simply removing the `.verify()` calls from the proposal method is not an option since o1js requires that proof inputs to a `@method` are verified, and includes the verification infrastructure in Pickles whenever a proof appears as a method input.

The fix is to not pass the proof objects to the proposal method at all: verify both proofs only in the treasury owner and pass just the public inputs and outputs to the proposal method as plain field values. The proposal's circuit would then use these values directly without re-verification, while still being constrained by the treasury owner's proof through the account-update composition.

Note that adopting the changes described in [Proposal Contract Methods Do Not Need Proofs](#finding-info-proposal-methods-dont-need-proofs) would eliminate this issue as well, since the tally logic would move into the treasury owner entirely.

**Client response.** Acknowledged.

---

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).
