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, a public request for comments 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. 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. 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 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:
- 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.
- Exploration Period. This serves as a dedicated time window for the community to review, discuss, and evaluate active proposals before voting begins.
- 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.
- 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.

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:
// 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
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.
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 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.
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:
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):
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:
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 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 and epoch 42 staking ledger in this Google Bucket |
| Voting period duration |
7,140 slots |
LIFECYCLE_PERIOD_DURATION in treasury-owner.ts |
| Average block production rate |
50% |
Mina Whitepaper (page 42, “f=0.5, the average fraction of filled slots in an epoch”) |
| Max zkApp transactions per block |
24 |
Mina Docs |
Calculation
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.
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:
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. 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.
Below are listed the findings found during the engagement. High severity findings can be seen as
so-called
"priority 0" issues that need fixing (potentially urgently). Medium severity findings are most often
serious
findings that have less impact (or are harder to exploit) than high-severity findings. Low severity
findings
are most often exploitable in contrived scenarios, if at all, but still warrant reflection. Findings
marked
as informational are general comments that did not fit any of the other criteria.