# End-to-End Audit of Mina's Decentralised Treasury

- **Client**: Mina
- **Date**: October 29th, 2026
- **Tags**: Poseidon, Merkle Trees, o1js, Mina

## Introduction

On September 8th, 2026, Mina Foundation tasked zkSecurity with a second security audit of its decentralised on-chain community treasury. The audit lasted six days with two consultants. We reviewed the [`MinaFoundation/decentralised-treasury`](https://github.com/MinaFoundation/decentralised-treasury) repository at commit [`98f5ed2`](https://github.com/MinaFoundation/decentralised-treasury/tree/98f5ed25129a874dc36e217403f3114ce51b716e).

This engagement followed our [first audit of the Mina treasury](https://reports.zksecurity.xyz/reports/mina-treasury-1), which reviewed the provable contracts and circuits at commit [`f2c93cf`](https://github.com/MinaFoundation/decentralised-treasury/tree/f2c93cfdb27836b156769ca4eaeca7d5f01fa8b5).

### Scope

The full `MinaFoundation/decentralised-treasury` repository at the frozen commit listed above was in scope. The audit covered the following security-critical paths:

- The complete treasury lifecycle, from staking-ledger ingestion and voting-ledger generation through proposal creation, voting, vote reduction and tallying, proposal execution, and emergency pause operations.
- Changes to the on-chain smart contracts and ZkPrograms introduced after the previous audit and remediation review, including their interfaces with off-chain components.
- The off-chain implementation used to construct transactions, generate proofs and witnesses, maintain voting and nullifier state, and persist protocol data across the SDK, CLI, workers, and relevant web application code.
- The indexer, processor, and API paths that derive application state from Mina Archive events, with a focus on event integrity, ordering, state synchronization, and consistency with on-chain state.
- End-to-end adversarial testing of the principal user and operator flows, with particular attention to trust boundaries between Mina and Archive data, persisted off-chain state, generated proofs, submitted transactions, and user-visible results.
- Changes in the custom [o1js fork](https://github.com/maht0rz/o1js/tree/87bc121acad6ba4d81df499e49ff44800c130ded) used by the treasury at commit `87bc121`, including Mesa support and modified primitives or semantics relied upon by the smart contracts and ZkPrograms. We reviewed the fork against upstream o1js v3.0.0.

The engagement did not include exhaustive review of presentational UI components, generic CRUD code unrelated to protocol integrity, local development infrastructure, or CI and DevOps configuration.

## Summary

A detailed overview of the Mina treasury architecture can be found in our [previous report](https://reports.zksecurity.xyz/reports/mina-treasury-1#overview).

Our audit uncovered several critical issues in o1js that affect the Mina ecosystem at large. These were disclosed to affected parties and addressed in a coordinated effort. See findings [#00](#finding-high-00-empty-key-account-update-pruning), [#01](#finding-high-01-nested-callee-token-id-not-bound) and [#02](#finding-high-02-unchecked-account-update-packing). As of writing, all three issues are addressed and released in [o1js v3.1.0](https://www.npmjs.com/package/o1js/v/3.1.0).

Our general assessment is that, after upgrading o1js, the protocol is close to Mainnet readiness. The main areas of tightening that remain are around the deployment setup, see [#03](#finding-high-03-public-mina-operator-graphql) and [#0c](#finding-medium-0c-docker-images-ship-env-files), and operational robustness so that the treasury doesn't fail on normal onchain conditions and edge cases ([#06](#finding-medium-06-account-strings-break-staking-ledger-import), [#07](#finding-medium-07-invalid-public-keys-block-mainnet-ledger), [#08](#finding-medium-08-event-length-ambiguity-halts-processor)).

**Update, 29 Oct 2026.** Mina Foundation has addressed all high, medium and low issues, including the deployment and robustness issues mentioned above. The Client Response of each finding describes its fix.

## Findings

### Permissionless Treasury Drain: Empty-Key Account Updates Are Pruned

- **Severity**: High
- **Location**: o1js/.../account-update.ts; contracts/treasury-owner.ts

**Description**. Anyone can withdraw funds from the Treasury Owner by calling `executeProposal()` with the empty public key as the Proposal address. The root cause is in o1js: it drops account updates at `PublicKey.empty()` from the final transaction, along with everything the caller proved about them.

Mina represents a zkApp transaction as a forest of AccountUpdates. For a nested smart-contract call, the caller proof commits to the callee AccountUpdate and its descendants. Mina then verifies the authorization attached to each update in the serialized transaction. This composition is sound only if every update committed to by the caller remains in the serialized call forest.

o1js uses AccountUpdates at `PublicKey.empty()` as internal dummy values. `AccountUpdate.isDummy()` identifies them solely by their public key:

```typescript
isDummy() {
  // ZKSECURITY: the empty public key alone marks an update as dummy
  return this.body.publicKey.isEmpty();
}
```

When o1js finalizes the call forest, it omits every update for which `isDummy()` returns true. Omitting a parent also removes its descendants:

```typescript
for (let { isDummy, ...tree } of [...nodes].reverse()) {
  // ZKSECURITY: an update at the empty key is dropped together with its subtree
  finalForest.pushIf(isDummy.not(), tree);
}
```

This test does not establish that the update is the canonical internal dummy. An update at the empty public key is removed even if it contains account preconditions, state changes, authorization data, or child updates. Application code can therefore prove statements about an AccountUpdate that does not appear in the resulting transaction.

This affects `TreasuryOwner.executeProposal()`. The method accepts a Proposal public key and constructs the corresponding Proposal under the Owner's derived token ID. It relies on nested Proposal calls to check the lifecycle, approval status, recipient, and payout amount. The Owner debit occurs outside those calls:

```typescript
// ZKSECURITY: proposalPublicKey is caller-supplied and may be PublicKey.empty()
const proposal = new TreasuryProposalSmartContract(
  proposalPublicKey,
  this.deriveTokenId(),
);

const proposalLifecycleId = await proposal.getLifecycleId();
// ZKSECURITY: ...

// ZKSECURITY: the debit lives on the Owner update, which is retained
this.self.balance.subInPlace(amountToPayOut);
// ZKSECURITY: all Proposal checks live on the Proposal updates, which are dropped
await proposal.execute(amountToPayOut, recipient);
this.approve(proposal.self);
```

An attacker can pass `PublicKey.empty()` as `proposalPublicKey` and provide a malicious local account witness while constructing the transaction. For `getLifecycleId()`, the witness supplies a lifecycle old enough to satisfy the check that remains in the Owner circuit.

The approval, recipient, and remaining-payout checks in `proposal.execute()` are not part of the Owner circuit. They run while o1js computes the witnessed callee update.

The Proposal updates contain the preconditions that would normally bind this state to an on-chain account. They also contain the Proposal authorization, the payout-accounting update, and the recipient payment. o1js classifies both Proposal updates as dummy and removes them along with their descendants and enforced protocol logic.

The nonempty Owner update remains, including its proof and balance debit. The attacker can add a separate top-level credit for the same amount. Mina then sees a balanced transaction with a valid Owner proof, but no Proposal update or Proposal proof.

**Proof of concept**. We reproduced this attack on an o1js `LocalBlockchain` with proofs enabled. The attacker called `executeProposal(PublicKey.empty(), attacker, 2 MINA)`. The serialized transaction contained the 2 MINA Owner debit and a matching attacker credit, but no update for the Proposal.

The transaction was accepted with a genuine Owner proof and only the attacker's fee-payer signature. The Owner lost 2 MINA and the attacker received 2 MINA. No Proposal account existed.

**Impact**. Anyone can permissionlessly drain the full balance of an unpaused Treasury Owner.

The issue is present in upstream o1js v3.0.0 and in the reviewed fork. It is not specific to Treasury: the same behavior can remove a nested AccountUpdate and its complete descendant subtree in other o1js applications.

**Recommendation**. We reported the underlying library issue to o1Labs, and it is being addressed in o1js. Mina Treasury must upgrade to the fixed o1js release before going live.

**Client Response**. Fixed in revision [`479bdc2`](https://github.com/MinaFoundation/decentralised-treasury/commit/479bdc2862c140f0d7f6d01280e1782bcca1bc95) by upgrading to o1js 3.1.0.

### Permissionless Treasury Drain: Nested Calls Do Not Bind the Token ID

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

**Description**. When one contract calls a method on another, o1js does not constrain the token ID of the callee account. This lets an attacker deploy their own fake Proposal contract under the native token and hand it to `executeProposal()` to drain the treasury.

A Mina account is identified by `(publicKey, tokenId)`. The same public key can therefore refer to distinct accounts under the native token and under a custom token. A nested call must bind both parts of the intended account identity.

o1js evaluates a nested smart-contract method inside `AccountUpdate.witness()`. The caller circuit then constrains the witnessed callee update to the contract instance selected by the caller. The public-key constraint is correct, but the token-ID constraint compares the witnessed value with itself:

```typescript
// ZKSECURITY: from now on, this.self returns the witnessed update
innerContext.selfUpdate = accountUpdate;

accountUpdate.body.publicKey.assertEquals(this.address);
// ZKSECURITY: this.self.body.tokenId is accountUpdate.body.tokenId, so this is a tautology
accountUpdate.body.tokenId.assertEquals(this.self.body.tokenId);
```

The token ID supplied when the `SmartContract` instance was constructed is available as `this.tokenId`, but is not constrained here. A prover can substitute a same-key account under another token ID if that account can produce a valid proof for the called method and verification key.

This affects `TreasuryOwner.executeProposal()`. The Treasury Owner intends to call a Proposal under its derived token ID:

```typescript
// ZKSECURITY: the derived token ID is never enforced on the witnessed Proposal updates
const proposal = new TreasuryProposalSmartContract(
  proposalPublicKey,
  this.deriveTokenId(),
);
```

An attacker can instead deploy a genuine `TreasuryProposalSmartContract` at an attacker-controlled public key under `TokenId.default`. The attacker chooses its initial state, including an old lifecycle, `APPROVED` status, recipient, and payout amount. The attacker then passes that public key to the real Owner.

During proof generation, the nested calls use the native-token Proposal. Their public-key checks pass, while the token-ID assertions impose no constraint. The Proposal proofs are valid for the native-token account and are retained in the transaction. The Owner proof nevertheless treats their results as coming from the Proposal account under its derived token ID.

**Proof of concept**. We reproduced the attack on `LocalBlockchain` with proofs enabled. The attacker deployed a native-token Proposal with attacker-selected approved state, then used it to request 2 MINA from an Owner funded with 10 MINA. The accepted transaction contained two proved updates for the native-token Proposal and no update under the Owner-derived Proposal token ID. The Owner lost 2 MINA, the attacker received 2 MINA, and the native-token Proposal recorded the payout.

**Impact**. Anyone can drain the full balance of an unpaused Treasury Owner.

The defective wrapper is present in upstream o1js v3.0.0 and in the reviewed fork. Other applications that call token-scoped contracts dynamically can likewise confuse same-key accounts under different token IDs.

**Recommendation**. We reported the underlying library issue to o1Labs, and it is being addressed in o1js. Mina Treasury must upgrade to the fixed o1js release before going live.

**Client Response**. Fixed in revision [`479bdc2`](https://github.com/MinaFoundation/decentralised-treasury/commit/479bdc2862c140f0d7f6d01280e1782bcca1bc95) by upgrading to o1js 3.1.0, which constrains the callee token ID to `this.tokenId`.

### Permissionless Treasury Drain: Unchecked AccountUpdate Packing Allows Authorization Substitution

- **Severity**: High
- **Location**: o1js/.../zkapp.ts; o1js/.../account-update.ts

**Description**. The caller circuit of a nested call hashes the callee AccountUpdate without checking that its fields are canonical, so the hash does not uniquely determine the update. A prover can make the Treasury Owner proof commit to a proof-authorized Proposal update while the transaction carries an unauthorized update with the same hash. By applying this substitution to `executeProposal()`, using any existing Proposal, the attacker bypasses all checks of the Proposal state (including for the amount allowed to withdraw) and drains the Treasury Owner.

o1js implements a nested smart-contract call by executing the callee inside an `AccountUpdate.witness()` block. The caller circuit does not contain the callee circuit. Instead, it commits to the witnessed callee AccountUpdate and relies on Mina to verify the authorization of the corresponding public AccountUpdate.

This requires the private witnessed update and the public serialized update to have a unique representation under the hash committed to by the caller proof. If two representations produce the same hash input, the caller can reason about one update while Mina receives another.

The nested-call wrapper skips the normal AccountUpdate checks:

```typescript
let {
  accountUpdate,
  result: { result, children },
} = await AccountUpdate.witness(
  provable({
    result: methodIntf.returnType ?? provable(null),
    children: AccountUpdateForest,
  }),
  runCalledContract,
  // ZKSECURITY: disables AccountUpdate.check() on the witnessed callee update
  { skipCheck: true },
);
```

With `skipCheck: true`, o1js applies only `AccountUpdate.clientSideOnlyChecks`, which checks the signed representation of the balance change:

```typescript
let accountUpdate = skipCheck
  ? {
      ...provable(AccountUpdate),
      // ZKSECURITY: this replacement check validates only the balance change
      check: AccountUpdate.clientSideOnlyChecks,
    }
  : AccountUpdate;
```

Because `AccountUpdate.check()` is skipped, most Boolean and bounded fields are not constrained to their ranges before the AccountUpdate hash is used.

An explicit check in the nested-call wrapper asserts that `authorizationKind.isProved == 1`, with the intention of ensuring that method calls have to carry associated proofs. However, the adjacent `isSigned` value is never constrained to be Boolean.

The hash input packs these flags as

$$
2 \cdot \mathsf{isSigned} + \mathsf{isProved}.
$$

A canonical update with None authorization has both flags set to zero. Its packed value is therefore zero. A malicious private witness can instead set

$$
\mathsf{isSigned} = -1/2, \qquad \mathsf{isProved} = 1.
$$

This satisfies the caller's `isProved == 1` assertion and also packs to zero. If both forms use the dummy verification-key hash, they produce the same complete AccountUpdate hash. This does not require a Poseidon collision: the unchecked values produce identical input before hashing.

The caller proof can consequently use a private witness that appears proof-authorized, while the serialized transaction contains the hash-equivalent canonical None-authorized update. The private witness can provide an arbitrary return value and an empty child forest. Mina sees only the canonical public update and does not require the proof or descendants assumed by the caller.

`TreasuryOwner.executeProposal()` relies on two nested Proposal calls. `getLifecycleId()` supplies the value checked by the Owner, while `execute()` checks the Proposal state and accounts for the payout. The Owner balance debit occurs outside both calls.

An attacker can target any Proposal and replace both nested calls with None-authorized no-op updates. Malformed private witnesses provide the return values needed to prove the Owner call. The corresponding public updates contain no Proposal proofs, state checks, payout-accounting update, or recipient payment. Proposal accounts use `Permissions.default()`, for which `access` is `none`, so Mina accepts the public no-op updates without authorization. A separate top-level credit to the attacker balances the retained Owner debit.

**Proof of concept**. We reproduced the packing ambiguity and Treasury attack on `LocalBlockchain` with proofs enabled. In one atomic transaction, the attacker created a Proposal for 1 nanomina and requested the Owner's full 10 MINA balance. The two execution updates used None authorization and contained no Proposal proof. The transaction was accepted and successfully paid out the full treasury balance minus the 1 MINA account creation fee to the attacker. The new Proposal remained `UNKNOWN`, its recorded amount remained 1 nanomina, and its `paidOutAmount` remained zero.

**Impact**. Anyone can withdraw up to the full balance of an unpaused Treasury Owner. The attack can use a Proposal created by another party during any lifecycle period, or create one on the spot during the `PROPOSAL` period.

The root cause applies to the packed AccountUpdate representation generally. Any field whose range or Boolean check is skipped may admit different values with the same packed representation. The affected code is present in upstream o1js v3.0.0 and in the reviewed fork.

**Recommendation**. We reported the underlying library issue to o1Labs. The upstream recommendation is to remove the AccountUpdate `skipCheck` option altogether and call `AccountUpdate.check()` in all code paths that witness account updates. Mina Treasury must upgrade to a fixed o1js release before going live.

**Client Response**. Fixed in revision [`479bdc2`](https://github.com/MinaFoundation/decentralised-treasury/commit/479bdc2862c140f0d7f6d01280e1782bcca1bc95) by upgrading to o1js 3.1.0, which removes `skipCheck` and applies the full `AccountUpdate.check()` to witnessed callee updates.

### Public Mina GraphQL Allows Node Reconfiguration and Archive Poisoning

- **Severity**: High
- **Location**: devops/runbooks/1-Network/1b-Mina-Daemon/helmfile.yaml; devops/runbooks/2-Treasury/2c-Deploy-Stack/helmfile.yaml

**Description**. The deployment exposes the Mina daemon's full GraphQL API to the internet without authentication. The daemon Service offers two ports: `3000`, served by a `graphql-public-proxy` sidecar that hides mutations, and `3085`, the full daemon API. Both public routes select `3085`:

```yaml
# 1b-Mina-Daemon/helmfile.yaml, internet-facing Ingress
service:
  name: graphql-proxy
  port:
    # ZKSECURITY: full daemon API instead of the read-only proxy on 3000
    number: 3085

# 2c-Deploy-Stack/helmfile.yaml, upstream of the Treasury's public /mina/ route
minaNodeUpstream: http://graphql-proxy.devnet.svc.cluster.local:3085
```

The full API contains operator mutations that assume a trusted caller. For example, `setConnectionGatingConfig` can isolate the node from its peers.

It also contains `archivePrecomputedBlock` and `archiveExtensionalBlock`, which forward a caller-supplied block to the Archive without validating it against consensus. The Archive decides finality by height alone, so an attacker who also injects a forged descendant more than `k` blocks higher makes the first block canonical. The Treasury indexer trusts canonical Archive events as its record of Proposal activity.

The proxy on port `3000` likely does not prevent these mutations either. The deployed proxy image (version `0.0.12`) is not public, but its most recent public build, the `minafoundation/graphql-public-proxy` image from July 2024, hides mutations only from HTTP requests. WebSocket connections are forwarded to the daemon unchanged, and the daemon also executes mutations received over a WebSocket.

**Proof of concept**. On Mesa Lightnet, we injected a block containing a fake `proposalCreated` event, with no signature or proof, and made it canonical with a forged descendant. The unmodified indexer stored the event. A second, duplicate creation event made the Proposal processor block permanently, so later legitimate events were no longer processed. On a multi-node Lightnet, a single `setConnectionGatingConfig` call with `isolate: true` disconnected a non-producing node from all its peers; its chain stopped advancing and it reported itself offline.

**Impact**. The Treasury web app shows Proposal state as reported by Archive events, without checking it against the chain. By injecting events, any internet client can therefore control what users see:

- Fake Proposals appear with an attacker-chosen amount, recipient and description. Voting on them fails, since no Proposal account exists.
- Real Proposals can be shown as approved, rejected, paused or paid out, with arbitrary vote counts. A fake vote for a real voter marks their actual vote as a duplicate.
- Real Proposals can be shown with fake terms, such as a small amount and a benign recipient. Voting on them succeeds, since the web app only checks that the Proposal account exists, so voters can be led to approve a payout they never saw.

Injected vote actions also break tallying: the tally prover reads them from the Archive, so the real Proposal can no longer be tallied until the Archive is cleaned, and every injection queues expensive proving work at no cost to the attacker. A single malformed event also halts the processing of all later Treasury events.

The attacker can also disconnect the Treasury's Mina node from its peers, which stops transaction submission and the Archive feed.

**Recommendation**. Point both the Ingress and `minaNodeUpstream` to port `3000`, and block WebSocket connections on both routes. If the read-only proxy is insufficient, use an allowlist that contains only the required queries and `sendZkapp`, for both HTTP and WebSocket requests. The full daemon API should be reachable only on a private, authenticated route. As defense in depth, the web app should compare a Proposal's amount and recipient with its on-chain state before voting.

**Client Response**. Fixed in revisions [`57167b6`](https://github.com/MinaFoundation/decentralised-treasury/commit/57167b6fcf2ae9af6c10a77a263646d7a7184879) and [`1c7a351`](https://github.com/MinaFoundation/decentralised-treasury/commit/1c7a3510b7ffb9206ddd0b2b178ad5bbc49ab227), with helm charts `mina-daemon-2.3.0` and `decentralized-treasury-0.10.2`. Public routes now go through version `0.0.14` of the proxy, which refuses WebSocket connections and hides all mutations except `sendZkapp`, and the full daemon API only listens inside its pod.

### Imported Signing Bundles Can Disguise a Break-Glass Operation

- **Severity**: High
- **Location**: apps/backoffice/features/backoffice-app.tsx; apps/backoffice/features/wallets.ts

**Description**. Break-glass operations on the Pause Controller require signatures from three of its five participants, which the Backoffice collects through imported JSON bundles. Each bundle contains the operation and its arguments together with the `messageHash` to be signed. The submitter validates the complete bundle with `verifyOperationPackage()`, but the signer only checks its structure:

```typescript
if (workflowRole === "signer") {
  // ZKSECURITY: messageHash is not recomputed from the displayed operation
  const pureOperation = assertPureSigningOperation(imported);
  setOperation(pureOperation);
  return;
}

const fresh = await onRefresh();
await verifyOperationPackage(imported, fresh);
```

The signer then signs the imported `messageHash` on the Ledger, which only displays it as an opaque field element.

**Impact**. A malicious coordinator can show signers a harmless operation while setting `messageHash` to the hash of a participant-set rotation. The three resulting signatures let the coordinator replace all break-glass participants with their own keys, and thereby permanently control pausing of the Treasury and its Proposals.

**Client Response**. Fixed in revision [`265b1c9`](https://github.com/MinaFoundation/decentralised-treasury/commit/265b1c9ac8548d8ff01db59944a8022e6c0dc6cb), which recomputes the message hash from the displayed operation before signing and rejects the bundle on mismatch.

### One Empty-Key Dummy Vote Permanently Prevents a Proposal Tally

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

**Description**. Anyone can make a Proposal impossible to tally by submitting a single vote with `Vote.DUMMY` and `PublicKey.empty()`. This is a second consequence of the empty-key pruning in o1js described in [the first finding](#finding-high-00-empty-key-account-update-pruning): casting a vote for the empty key should require a signature that nobody can produce, but o1js drops that requirement.

`TreasuryOwner.vote()` accepts both values:

```typescript
// ZKSECURITY: assertValid() admits Vote.DUMMY
Vote.assertValid(vote);

// ZKSECURITY: publicKey may be PublicKey.empty()
await proposal.vote({ vote, publicKey });

// ZKSECURITY: pruned by o1js when publicKey is empty, so no signature is needed
AccountUpdate.createSigned(publicKey);
```

The signed voter update is meant to prove control of the voting key. For the empty key, o1js removes it from the transaction, so the attacker only signs as the sender.

The resulting action is special: the Vote Reducer uses the same value as batch padding and leaves it out of the action hash, while Mina appends it to the Proposal's action state like any other action:

```typescript
const isDummyVoteAction = VoteAction.isDummy(voteAction);
// ...
toActionsHash = Provable.if(
  // ZKSECURITY: true for (Vote.DUMMY, PublicKey.empty()), whether padding or a real action
  isDummyVoteAction,
  toActionsHash,
  appendActionToHashList(toActionsHash, VoteAction.toFields(voteAction)),
);
```

After the attack, no Vote Reducer proof can reach the Proposal's action state, which `tallyVotes()` requires.

**Proof of concept**. On `LocalBlockchain` with proofs enabled, an attacker submitted `TreasuryOwner.vote(proposal, PublicKey.empty(), Vote.DUMMY)`, signed only with their own key. The Proposal's action state included the action, while the unmodified Vote Reducer left its hash unchanged.

**Impact**. For one transaction fee, anyone can prevent a chosen Proposal from ever being tallied, without voting weight or privileged keys. Repeating this against every Proposal blocks all governed Treasury payouts. No funds can be stolen this way.

**Recommendation**. Mina Treasury must upgrade to the fixed o1js release. The voter update then requires a signature for the empty key, which prevents the attack. For extra robustness, `TreasuryOwner.vote()` could also reject `Vote.DUMMY`, so that padding values are never dispatched as real actions.

**Client Response**. Fixed in revisions [`479bdc2`](https://github.com/MinaFoundation/decentralised-treasury/commit/479bdc2862c140f0d7f6d01280e1782bcca1bc95) and [`519cb86`](https://github.com/MinaFoundation/decentralised-treasury/commit/519cb86a056728083070128b0a89f0c8c18fdf43), by upgrading to o1js 3.1.0 and by rejecting `Vote.DUMMY` in `TreasuryOwner.vote()`.

### Any Account Can Break the Staking-Ledger Import by Setting Its Own Token Symbol or zkApp URI

- **Severity**: Medium
- **Location**: ledgers/staking-ledger/staking-ledger.ts; o1js/.../poseidon.ts

**Description**. The Treasury imports the full Mina staking ledger from its JSON export and must reconstruct exactly the ledger root committed on chain. Two account fields are byte strings in Mina, which any account can set on itself with a signature, but are decoded as JavaScript strings by the Treasury:

```typescript
public async parseStakingLedgerAccount(value: any): Promise<Account> {
  return new Account({
    // ...
    // ZKSECURITY: throws on any non-ASCII character
    tokenSymbol: TokenSymbol.from(value.token_symbol),
    // ...
    // ZKSECURITY: hashes the string as decoded by Node, not the original bytes
    zkappUri: ZkappUri.from(value.zkapp.zkapp_uri).hash,
    // ...
```

- **Token symbol**: Mina only requires at most six bytes. o1js's `TokenSymbol.from()` rejects every non-ASCII character, for example `"é"`, so a single such account makes the whole import fail. This check is upstream o1js behavior.
- **zkApp URI**: Mina only requires at most 255 bytes, not valid UTF-8. Via the daemon's GraphQL API, a URI can be set to invalid UTF-8 bytes, which the ledger export writes out unchanged. Node replaces them with U+FFFD when reading the file, so the Treasury hashes a different URI and reconstructs a different ledger root. Here the import succeeds, but no staking-ledger proof can match the on-chain root.

**Proof of concept**. On `LocalBlockchain`, which applies transactions through the native Mina ledger code, an ordinary account set its token symbol to `"é"` with a signed update; `TokenSymbol.from()` then throws on the stored value. For the URI, we sent a signed update with the invalid UTF-8 bytes `ed b0 80` to the public GraphQL endpoint of a Mesa Lightnet node. The transaction succeeded and Mina stored the exact bytes. On a ledger containing such a URI, the root computed by the official Mina binary differs from the one reconstructed by the Treasury's parser.

**Impact**. For one transaction fee, anyone can prevent the current Treasury from using every future staking ledger. Without it, no voting ledger can be derived, so no Proposal can be tallied or executed. The attacker's account keeps its value, so the problem persists across epochs until the parser is fixed.

**Recommendation**. Treat token symbols and zkApp URIs as byte strings, like Mina. The importer should read them from the export as raw bytes instead of decoding them as UTF-8, and compute the token symbol field and zkApp URI hash from those bytes as Mina does. The o1js string-based constructors can't do this. The same applies to account storage, which serializes the token symbol as its string with the `toJSON()` method that o1js derives for `Account`, and recomputes the field in `Account.fromJSON()`. Storing the field itself, e.g. with a custom token-symbol type in `Account` that keeps the same provable layout, avoids this without changing the circuit.

In addition, remove the `try`/`catch` in `Account.fromJSON()` that silently returns `Account.empty()` on any parse error. The account storage relies on this method, so parse errors currently turn into a wrong ledger root instead of a visible failure (see also [the invalid public key finding](#finding-medium-07-invalid-public-keys-block-mainnet-ledger)).

**Client Response**. Fixed in revision [`96b2780`](https://github.com/MinaFoundation/decentralised-treasury/commit/96b2780889a35003e48a4b73534ed4a0c6903af1), which reads token symbols and zkApp URIs from the export as raw bytes, hashes and stores them as Mina does, and removes the `try`/`catch` from `Account.fromJSON()`.

### Two Mainnet Accounts With Invalid Public Keys Block Every Tally

- **Severity**: Medium
- **Location**: ledgers/staking-ledger/staking-ledger.ts; provable/account.ts

**Description**. Mina stores public keys in compressed form, as an x-coordinate and a parity bit, and does not require the x-coordinate to lie on the curve. The Mainnet ledger contains two such accounts, which nobody can sign for:

- `B62qpyhbvLobnd4Mb52vP7LPFAasb2S6Qphq8h5VV8Sq1m7VNK1VZcW`
- `B62qqdcf6K9HyBSaxqH5JVFJkc1SUEe1VzDc5kYZFQZXWSQyGHoino1`

The Treasury's `parseStakingLedgerAccount()` parses the account key and delegate of every staking-ledger record with `PublicKey.fromBase58()`. That function checks that the key is a valid curve point, and throws `PublicKey: not a valid group element` for both accounts. The import of the full ledger fails as a result.

Fixing only the file parser is not enough. The account storage codec (`Account.fromJSON()`) silently replaces such accounts with an empty account, which changes the ledger root. The voting-ledger index helper `publicKeyBase58ToBigInt()` parses the delegate with `PublicKey.fromBase58()` again.

**Proof of concept**. We ran `parseStakingLedgerAccount()` over an official Mainnet staking-ledger export from September 18, 2026 (288,898 accounts), whose root matches the on-chain ledger hash. It rejected exactly the two accounts above, which each hold 66,000 MINA and delegate to themselves. The Treasury circuits accept both accounts once they are decoded without the curve check.

**Impact**. On Mainnet, the Treasury cannot import any staking ledger, and therefore cannot tally a Proposal or execute a payout. No attacker is needed. Since the accounts can't sign, they will stay in the ledger unless Mina removes them in a protocol upgrade. The current Devnet ledger is not affected.

**Recommendation**. Decode ledger public keys from Base58 without requiring a valid curve point, as Mina does, and construct them with `PublicKey.from({ x, isOdd })`. This is needed in `parseStakingLedgerAccount()`, in `Account.fromJSON()` for account storage, and in `publicKeyBase58ToBigInt()`.

**Client Response**. Fixed in revision [`96b2780`](https://github.com/MinaFoundation/decentralised-treasury/commit/96b2780889a35003e48a4b73534ed4a0c6903af1), which decodes ledger public keys without requiring a curve point in the parser, account storage, and `publicKeyBase58ToBigInt()`.

### A Shortened On-Chain Event Permanently Halts the Proposal Processor

- **Severity**: Medium
- **Location**: o1js/.../events.ts; apps/api/src/processors/proposals/proposal-event-decoding.ts

**Description**. Mina commits to each event by hashing its field array with Poseidon, without the array length. Poseidon pads an odd-length input with a zero, so an event ending in a zero field has the same hash as the event without that zero:

```typescript
function hashWithPrefix(prefix: string, input: Field[]) {
  let init = salt(prefix);
  // ZKSECURITY: [...fields, 0] and [...fields] hash identically if fields.length is odd
  return Poseidon.update(init, input)[0];
}
```

Only the event hash is part of the proved AccountUpdate, while the event fields are carried as auxiliary data. Anyone submitting a transaction can therefore drop a trailing zero from an event without invalidating the proof or signatures.

All Treasury Owner events have an even number of fields and end with the sender's public key, whose last field is its `isOdd` flag. Any sender with an even key can thus shorten their event by one field. The Treasury processor receives the event with one field fewer than expected, and mistakes it for a legacy format:

```typescript
if (
  // ZKSECURITY: a shortened current event has exactly this length
  data.length === payloadFieldCount ||
  legacyFieldCounts.includes(data.length)
) {
  // ZKSECURITY: the discriminator is not removed, so all fields are shifted by one
  return { kind: "fields", fields: data };
}
```

Each event handler decodes these fields with its event's `fromFields()` and then runs `PublicKey.check()` on the decoded keys. After the shift, the Proposal key's `isOdd` flag holds an x-coordinate, so the check throws and the handler returns `false`. The event router then reports the event as unhandled.

`EventsProcessor` handles all Treasury events in a single global order. It treats an unhandled event as a failure, and after five failed attempts marks it as `blocked`. The processor's cursor only moves past successfully handled events, and while a blocked event exists, the processor stops fetching new ones. No later Treasury event is processed.

**Proof of concept**. With the Treasury contracts deployed on Mesa Lightnet, we paused a Proposal and removed the final zero from the emitted event before proving. The node included the transaction and the Proposal was paused; the Archive returned the shortened event. Given this event, the unmodified Proposal event handler rejected it and the router reported it as unhandled, which makes the processor block.

**Impact**. Any user can halt the Proposal projection, which the web app relies on, until the processor is fixed. The cheapest way is to cast a vote during the voting period. The event is part of a valid transaction on chain, so it can't be cleaned up.

**Recommendation**. Decode events by their type and schema instead of inferring the format from their length. A current event that is one field short should be treated as the same event with a trailing zero. More generally, an event that can't be decoded should not halt processing of all other events.

**Client Response**. Fixed in revisions [`dfe6234`](https://github.com/MinaFoundation/decentralised-treasury/commit/dfe62346c839c884042227f5fa614ce434e88cf2) and [`338e4ed`](https://github.com/MinaFoundation/decentralised-treasury/commit/338e4edf69c567954ab880c8fe67d39d2b1ed82a), which decode events by their discriminator and schema, restore a dropped trailing zero, and quarantine undecodable events instead of blocking the processor.

### o1js Fork: `UInt128` Multiplication and Division Are Underconstrained

- **Severity**: Medium
- **Location**: o1js/src/lib/provable/int.ts

**Description**. The o1js fork adds a `UInt128` type, which is not part of upstream o1js. Its multiplication and division are computed in the native field, whose modulus $p$ is about $2^{254}$. Since two 128-bit values can have a product of almost $2^{256}$, these operations can wrap around $p$, and the constraints don't prevent this.

`mul()` multiplies in the field and only range-checks the result:

```typescript
mul(y: UInt128 | number) {
  // ZKSECURITY: the integer product can exceed p, and only its reduction mod p is checked
  let z = this.value.mul(UInt128.from(y).value);
  RangeCheck.rangeCheckN(UInt128.NUM_BITS, z);
  return new UInt128(z.value);
}
```

If the product exceeds $2^{128}$ but its reduction modulo $p$ is below $2^{128}$, the overflow goes undetected. For example, $2^{127} \cdot (2^{127} + 1)$ returns $124580867929049525641406541344198885375$.

`divMod()` witnesses the quotient $q$, computes the remainder $r = x - q y$ in the field, and checks that $q$ and $r$ fit in 128 bits and $r < y$:

```typescript
let q = Provable.witness(Field, () => new Field(x.toBigInt() / y_.toBigInt()));
RangeCheck.rangeCheckN(UInt128.NUM_BITS, q);

// ZKSECURITY: only proves x = q*y + r modulo p
let r = x.sub(q.mul(y_)).seal();
RangeCheck.rangeCheckN(UInt128.NUM_BITS, r);
// ...
r_.assertLessThan(new UInt128(y_.value));
```

Since $q y$ can exceed $p$, a malicious prover can choose a different quotient for which $q y + r = x + p$. For example, for $x = 0$ and $y = 2^{127} + 1$, the prover can return $q = 2^{127} - 1$ and $r = 45560315531419706090280762371685220354$ instead of zero.

**Impact**. Circuits using `UInt128` can accept wrong products and quotients for large inputs. This does not currently affect the Treasury, where all values entering `UInt128` arithmetic are always smaller than $2^{96}$, so that no intermediate result gets close to $p$. However, the type is part of a general-purpose library fork and is likely to be reused.

**Recommendation**. Deprecate `UInt128` in the o1js fork, and use a more constrained type (for instance, `UInt96`).

**Client Response**. Fixed in o1js fork revisions [`ac7eb03`](https://github.com/maht0rz/o1js/commit/ac7eb036637f39e054ca68555c250f645a532da4) and [`d670b3e`](https://github.com/maht0rz/o1js/commit/d670b3efd4fc7f0bf431a0b2211c28b1dc257944), which constrain `UInt128` multiplication and division over the integers, deprecate `UInt128`, and add a `UInt96` type. The Treasury arithmetic was migrated to `UInt96` in revision [`dbdd613`](https://github.com/MinaFoundation/decentralised-treasury/commit/dbdd613d3dd633c67bc57f9f812ced930b7cb180).

### Deploying the Treasury Owner to an Existing Account Lets Anyone Plant an Approved Proposal

- **Severity**: Medium
- **Location**: services/sqlite/sqlite-treasury-owner-service.ts; apps/cli/src/commands/treasury-owner.ts

**Description**. The Treasury Owner is an o1js `TokenContract`, and its Proposals are accounts under the Owner's token ID. `TokenContract.deploy()` requires the Owner account to be new: before deployment, the account's `access` permission is `none`, so anyone can create accounts under its token ID.

The CLI's `--allow-deploy-to-existing-account` flag (also settable through `ALLOW_DEPLOY_TO_EXISTING_ACCOUNT`) removes this precondition:

```typescript
async () => {
  if (!allowDeployToExistingAccount) {
    AccountUpdate.fundNewAccount(senderPublicKey, 1);
  }
  await treasuryOwner.deployWithWithdrawalPermission(withdrawalPermission);
  if (allowDeployToExistingAccount) {
    // ZKSECURITY: accepts an existing account, which may already have token accounts
    treasuryOwner.account.isNew.requireNothing();
  }
},
```

If the flag is used for the initial deployment, an attacker can prepare a Proposal account under the future Owner's token ID beforehand. It gets the real Proposal verification key and state of the attacker's choice: `APPROVED`, an attacker-controlled recipient and an arbitrary amount. The deployment does not touch this account, so afterwards, the attacker can call `executeProposal()` on it and is paid out by the Owner.

The documentation says to keep the flag disabled for an initial deployment, and according to the Mina developers, it mostly exists for testing. However, an attacker can create this situation: once the Owner's deployment transaction appears in the mempool, the attacker can send MINA to the Owner address first. The deployment then fails because the account is no longer new, and retrying with `--allow-deploy-to-existing-account` is an obvious fix, but plays directly into the attacker hands.

**Proof of concept**. On `LocalBlockchain` with proofs disabled, an attacker created an approved Proposal account under the token ID of an address before the Owner was deployed there with `--allow-deploy-to-existing-account`. After deployment, calling `executeProposal()` on this Proposal moved 3 MINA from the Owner to the attacker.

**Impact**. If `--allow-deploy-to-existing-account` is used for the initial deployment, an attacker that monitors the chain can drain the Treasury Owner without creating, voting on or tallying a Proposal.

**Recommendation**. Remove the flag from the deployment tooling, or restrict it to local test networks. A deployed Owner cannot be redeployed anyway, since its verification key and permissions can't be changed. If an initial deployment fails because the address already exists, deploy to a fresh address instead.

**Client Response**. Fixed in revision [`e1ba69e`](https://github.com/MinaFoundation/decentralised-treasury/commit/e1ba69e35d9ab49203e7235e7acb08237182ff6f), which removes the `--allow-deploy-to-existing-account` flag, so the Owner can only be deployed to a new account.

### Tracking Pixels Can Be Injected in Proposals

- **Severity**: Medium
- **Location**: packages/ui/src/treasury/proposals/proposal-detail.tsx

**Description**. `treasury/proposals/proposal-detail.tsx:932-936` uses `ReactMarkdown` to render a user-supplied string as Markdown, where images (for example, `![](https://example.com)`) are allowed. Additionally, CSP is not configured and one could create tracking pixels in this way.

**Impact**. A malicious party could create a proposal to link voters' public keys with their devices (IP addresses, browsers and everything sent via the HTTP header).

**Recommendation**. Set a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) whose `img-src` excludes external origins, or disallow images in the Markdown renderer.

**Client Response**. Fixed in revision [`1b5bfd0`](https://github.com/MinaFoundation/decentralised-treasury/commit/1b5bfd0cec9225cf08932d5336b74878e6353067), which renders Markdown images in Proposals as their alt text.

### Published Docker Images Contain the Operator's Environment Files

- **Severity**: Medium
- **Location**: .dockerignore; devops/scripts/publish-images.sh

**Description**. The service images are built with `COPY . .` from the operator's checkout, and published with `devops/scripts/publish-images.sh`. `.dockerignore` only excludes environment files at the repository root and in `devops/`, so every nested one is copied into the image, for example `apps/cli/.env.testnet` and `apps/api/.env.testnet`. The publish script only refuses to run if `git status` shows changes, which does not cover these gitignored files.

The CLI environment file written by the bootstrap script contains the private keys of the sender, the Treasury Owner and the Pause Controller. For the Devnet deployment, a single command printed them from the public image:

```bash
docker run --rm --entrypoint cat minafoundation/dt-api:41c4809 /app/apps/cli/.env.testnet
```

The image of the latest revision, `minafoundation/dt-api:265b1c9`, contains a different set of these keys, and the database password of the API. According to the Mina developers, production deployments will use Ledger signing, and the private keys in these files are only used for development.

**Impact**. Whatever an operator puts into these files is published with the images. The environment files hold secrets by design, so this is a permanent risk for any credentials that end up there.

**Recommendation**. Exclude all environment files from the image, e.g. with a `**/.env*` rule in `.dockerignore`, and remove the published images that contain them.

**Client Response**. Fixed in revision [`2784c29`](https://github.com/MinaFoundation/decentralised-treasury/commit/2784c291f8f1ba3249a6030e94696f121a667d9d), which excludes `.env*` files at any depth from the Docker build context.

### Anyone Can Detach the Break-Glass Authorization and Block Pausing a Proposal

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

**Description**. To pause or unpause a single Proposal, `TreasuryOwner.togglePauseProposal()` makes two separate calls. The Pause Controller checks the break-glass signatures and increments its nonce, and the Proposal toggles its state:

```typescript
// ZKSECURITY: checks signatures over (proposalPublicKey, nonce) and increments the Controller nonce
await pauseController.togglePauseProposal(proposalPublicKey, signatures, nonce);

// ZKSECURITY: the actual state change happens in a sibling call
await proposal.togglePause();
```

The two calls are only tied together by the Owner's proof. The proof of the Controller call commits to its own AccountUpdate and its children, but not to its parent, and the call depth that places it under the Owner is not part of the AccountUpdate hash. Anyone who sees the transaction, for example in a node's public transaction pool, can therefore copy the Controller update, move it to the top level, and submit it on its own with their own fee payer. The copy increments the Controller nonce without toggling the Proposal, and the original transaction then fails because its nonce is outdated.

**Proof of concept**. On `LocalBlockchain` with proofs enabled, we detached the Controller update from a toggle transaction and submitted it on its own, signed only by an unrelated fee payer. The Controller nonce advanced from 1 to 2 without any pause, and the original transaction then failed with `Account_nonce_precondition_unsatisfied`.

**Impact**. Anyone can prevent a Proposal from being paused or unpaused, by repeating this for every new attempt, for one transaction fee each. For example, the proposer of an approved Proposal that the break-glass participants want to pause can block the pause and execute the payout meanwhile. Pausing the whole Treasury still works, but stops all other Proposals too.

**Recommendation**. Consume the authorization in the same AccountUpdate that changes the Proposal. For example, give each Proposal its own pause nonce, and let the Proposal check and increment it while setting an explicit target state:

```typescript
@state(UInt32) pauseNonce = State<UInt32>();

@method
public async setPaused(nonce: UInt32, paused: Bool) {
  const pauseNonce = this.pauseNonce.getAndRequireEquals();
  pauseNonce.equals(nonce).assertTrue("Invalid proposal pause nonce");
  const status = this.status.getAndRequireEquals();
  status.equals(ProposalStatus.PAUSED).equals(paused)
    .assertFalse("Proposal pause state already matches target");
  this.status.set(
    Provable.if(paused, ProposalStatus.PAUSED, ProposalStatus.UNKNOWN),
  );
  this.pauseNonce.set(pauseNonce.add(1));
}
```

The Pause Controller then only verifies the signatures, over the Proposal's key and token ID, the nonce and the target state, without changing its own state, so a detached Controller update has no effect. We tested this change with proofs enabled: detaching the Controller update no longer affects the original transaction, and replaying signatures or submitting the wrong target state fails.

This also fixes [`togglePause()` Does Not Take the Intended Pause State as Input](https://reports.zksecurity.xyz/reports/mina-treasury-1#finding-info-toggle-pause-missing-intended-state) from our previous report.

**Client Response**. Fixed in revision [`825de0e`](https://github.com/MinaFoundation/decentralised-treasury/commit/825de0e4d76d3b566fa2a069e13269f07099698a) as recommended, with a per-Proposal pause nonce checked and incremented in `setPaused()`, and signatures over the Proposal's key, token ID, nonce and target state.

### Contracts Are Compiled for Devnet, so No Treasury Method Can Be Proved on Mainnet

- **Severity**: Low
- **Location**: apps/

**Description**. In o1js, the circuit of every SmartContract method depends on the network. The method hashes its own AccountUpdate in `AccountUpdate.hash()`, whose prefix is taken from the active Mina instance, and Mainnet uses a different prefix than Devnet. Until `Mina.setActiveInstance()` is called, o1js uses a default instance with network ID `devnet`.

All Treasury clients compile the contracts before they select the network. The CLI commands call `compile()` before `configureMinaNetwork()`, for example in the Owner deployment and in `proposal create`. The web app's prover worker compiles all three contracts in `compileProposalContractsInCurrentThread()` and only calls `Mina.setActiveInstance()` later, when building a transaction. The backoffice does the same. The contracts are therefore always compiled for Devnet.

**Impact**. On Mainnet, every proof for a Treasury method fails, so no Proposal can be created, voted on or executed, and no break-glass operation works. The Owner and Pause Controller are deployed with signatures, so a deployment from the CLI succeeds, but installs verification keys that can't prove anything on Mainnet. Since `setVerificationKey` is `impossibleDuringCurrentVersion`, these accounts can't be fixed before the next hard fork and have to be replaced. Testing on Devnet or Lightnet cannot catch this, but a Mainnet test deployment that calls a contract method will.

**Recommendation**. Select the network before compiling any contract, in every process and worker, and include the network ID in the keys of the compile caches.

**Client Response**. Fixed in revision [`af1d956`](https://github.com/MinaFoundation/decentralised-treasury/commit/af1d956ad735f78fcb287f2ea27b3212695073ac) by selecting the network before compiling in every client and worker, with Mainnet as the default, and separating the compile caches by network.

### Proposals Created Before the Owner Enters the Staking Snapshot Can Never Be Tallied

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

**Description**. `createProposal()` records the current `stakingEpochData` ledger hash in the new Proposal, and every later tally uses this ledger. Besides the voting weights, `tallyVotes()` reads the Treasury Owner's balance from it, and requires a Merkle witness for the Owner account under the recorded root:

```typescript
const calculatedStakingLedgerRoot =
  treasuryOwnerAccountWitness.calculateRoot(
    treasuryOwnerAccountLeaf,
    accountLedgerHashPrefixes,
  );
// ZKSECURITY: impossible if the Owner account is not in the recorded ledger
calculatedStakingLedgerRoot.assertEquals(
  stakingEpochDataLedgerHash,
  "Treasury owner account witness does not match staking ledger hash",
);
```

In Mina, a newly created account only appears in `stakingEpochData` after about two epoch transitions. Proposals created before the Owner appears record a ledger without it, and can never be tallied. This only becomes visible at the tally, after the full voting period and proving run.

**Impact**. Nothing prevents such Proposals. The deployment runbook's example starts the first lifecycle at the current epoch, and it explicitly allows a start slot in the past. The first proposal period can therefore open right after deployment, and Proposals created in the first epochs can never be tallied.

**Recommendation**. Only open the first proposal period once `stakingEpochData` contains the Owner account with a nonzero balance, for example by rejecting deployment configurations whose first proposal period starts too early.

**Client Response**. Fixed in revision [`f064b62`](https://github.com/MinaFoundation/decentralised-treasury/commit/f064b6206d700c7eded50fc300e5d415c97aa6f6) by requiring a Merkle witness for the Owner account in `createProposal()`, which proves that the Owner is in the recorded staking ledger with a positive balance.

### Adjacent Lifecycle Periods Overlap by One Slot

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

**Description**. This issue was pointed out to us by the Mina developers during the audit. We include it for completeness.

`getLifecyclePeriodSlotRange()` computes each period's slot range as:

```typescript
const toSlot = Provable.if(
  noUpperBoundToSlot,
  UInt32.MAXINT(),
  // ZKSECURITY: requireBetween() includes this slot, which is also the next period's first slot
  fromSlot.add(TreasuryOwnerSmartContract.lifecyclePeriodDuration),
);
```

Since `requireBetween()` treats both bounds as inclusive, each period has one slot too many, and its last slot is also the first slot of the next period.

**Impact**. This has two effects:

- With the default period length of one epoch, the last slot of the proposal period is already in the next epoch. A Proposal created in that slot records the next epoch's staking ledger, while the off-chain pipeline prepares one staking ledger per lifecycle, taken from the lifecycle's epoch. Such a Proposal can't be tallied.
- In the slot shared by the voting and cooldown periods, both a vote and the tally are valid, so the transaction order decides whether a vote in that slot is counted. A vote included after the tally is accepted, but never counted.

The processor also fails on the events of a Proposal with the wrong staking ledger, which blocks all event processing as described in [the finding on blocking the Proposal processor](#finding-low-13-transient-failure-blocks-processor).

**Recommendation**. Make the upper bound exclusive, i.e., end each period at `fromSlot + lifecyclePeriodDuration - 1`.

**Client Response**. Fixed in revision [`ac1834b`](https://github.com/MinaFoundation/decentralised-treasury/commit/ac1834bc79a27e6e026f78d72d839e2d5a3218f2) by ending each bounded period at `fromSlot + lifecyclePeriodDuration - 1`.

### Vote Reducer State Is Shared Across the Proposals of a Lifecycle

- **Severity**: Low
- **Location**: storage/keyv/keyv-vote-nullifier-storage.ts; proving/tracing/vote-reducer-tracer.ts

**Description**. To tally a Proposal, the Vote Reducer processes its votes and tracks which voters have already voted in a nullifier tree. The Proposal's `tallyVotes()` requires this tree to start out empty:

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

However, the off-chain proving state is stored per lifecycle, not per Proposal. The nullifier tree, batch traces and proofs all live in one SQLite file per lifecycle, under namespaces that only contain the lifecycle ID:

```typescript
static namespaceFrom(lifecycleId: string): string {
  // ZKSECURITY: shared by all Proposals of the lifecycle
  return `nullifier-ledger-${lifecycleId}-nullifiers`;
}
```

The tracer starts each run from the persisted nullifier root. After the first Proposal of a lifecycle has been processed, that root contains its voters, so the proof for the next Proposal no longer starts from the empty tree. The documented proving flow runs every Proposal with the same `--lifecycle-id`, and describes the `vote-reducer clear-state` command, which would reset the tree, only as a tool for retries.

**Proof of concept**. We traced two Proposals of the same lifecycle through the Treasury's SQLite vote reducer service and generated a valid Vote Reducer proof for the second one, with proofs enabled. Its `fromNullifierRoot` was non-empty, so it fails the check in `tallyVotes()`.

**Impact**. Tallies are proved manually with the CLI. Following the proving documentation, the tally of the second Proposal with votes in a lifecycle fails when the tally transaction is built, after the full proving run. Running `vote-reducer clear-state` before each Proposal avoids this, but the documentation only describes that command for retries.

**Recommendation**. Key the nullifier tree, traces and proofs by lifecycle and Proposal, and check that the nullifier tree is empty before tracing a Proposal.

**Client Response**. Fixed in revision [`b1815ca`](https://github.com/MinaFoundation/decentralised-treasury/commit/b1815cacb9d6521f753419a51cd3a82d9d07fe75) by keying the nullifier tree, traces and proofs by lifecycle, Proposal address and token ID, and by refusing to trace when the nullifier tree is not empty.

### Restoring a Snapshot Checkpoint Leaves a Stale SQLite WAL, so Every Interruption Restarts the Full Build

- **Severity**: Low
- **Location**: apps/cli/src/lib/s3-checkpoint.ts; devops/docker/voting-ledger-scheduler-entrypoint.sh

**Description**. The voting-ledger scheduler builds each lifecycle's snapshot database, which takes many hours on Mainnet. Since it is meant to run on spot nodes, `trace-digest` regularly uploads a checkpoint of the database to S3, and after a restart, `process_candidate()` in `voting-ledger-scheduler-entrypoint.sh` restores the checkpoint instead of starting over.

The database runs in SQLite's WAL mode, where recent writes live in a separate `-wal` file until they are merged into the main file. When the process is interrupted, this file stays on the persistent volume. This even happens on a regular SIGTERM, since the handler in `staking-ledger-to-voting-ledger.ts` ends with `process.exit(143)` without closing the database.

`pullCheckpoint()` then writes the downloaded checkpoint over the main file, but leaves the old `-wal` and `-shm` files in place. The scheduler only deletes them when building from scratch, not when restoring. When the database is opened, SQLite applies the stale WAL to the restored file, which fails with `database disk image is malformed`, and the scheduler falls back to a full build.

**Impact**. Checkpoints never take effect after an interruption, so every spot interruption restarts the full build. If interruptions are frequent, the snapshot of a lifecycle is never completed, and none of its Proposals can be tallied.

**Recommendation**. Delete the `-wal`, `-shm` and `-journal` files before restoring a checkpoint, or download it to a temporary file and rename it. Close the database properly on SIGTERM.

**Client Response**. Fixed in revision [`c83a7f0`](https://github.com/MinaFoundation/decentralised-treasury/commit/c83a7f044fa6121162b51a7ba3abe6f3c7fa0540), which downloads the checkpoint to a temporary file, deletes the `-wal`, `-shm` and `-journal` files before renaming it, and closes the database on SIGTERM.

### A Transient Failure Permanently Blocks the Proposal Processor

- **Severity**: Low
- **Location**: packages/processor/src/events-processor.ts; apps/api/src/processors/proposals/

**Description**. The Proposal processor handles all Treasury events in a single global order. If handling an event fails five times, `EventsProcessor.processEventWithRetries()` marks it as `blocked` and stops processing events until an operator retries it manually. It does not distinguish permanent failures from transient ones.

A vote event depends on the staking-ledger snapshot of its lifecycle, which is prepared by the voting-ledger scheduler (`process_candidate()` in `devops/docker/voting-ledger-scheduler-entrypoint.sh`). If the snapshot is not available yet, the vote-weight lookup in `StakingLedgerVoteWeightResolver.loadWeights()` throws `data for lifecycleid is not available`. The processor blocks within about 15 seconds and stays blocked after the snapshot becomes available.

Other transient conditions have the same effect. The scheduler builds the snapshot file `<lifecycleId>.sqlite` in place, which takes several hours, and only writes a `.done` marker at the end. `LifecycleStakingLedgerServiceRegistry.getService()` opens the file as soon as it exists, without checking for the marker, and caches it for the lifetime of the process. Events of a lifecycle whose snapshot is still being built fail with a root mismatch, in `StakingLedgerVoteWeightResolver.assertRoot()` or `ProposalCreatedEventHandler.getTreasuryBalanceForLifecycle()`. After a rebuild, the cached handle keeps pointing to the old snapshot.

Since the readiness check of the public API calls `assertProcessorReady()`, the API is also reported as unavailable while the processor is blocked. The same mechanism turns the malicious events from [the public GraphQL finding](#finding-high-03-public-mina-operator-graphql) and [the shortened event finding](#finding-medium-08-event-length-ambiguity-halts-processor) into a global halt.

**Impact**. Normal operation can halt the Proposal projection and take the public API offline until an operator intervenes.

**Recommendation**. Only open a snapshot once the scheduler has marked it complete, and reopen it after a rebuild. Retry transient failures, such as a missing snapshot, with backoff instead of blocking. Only block on events that can never be handled, and do not let these halt the processing of unrelated Proposals.

**Client Response**. Fixed in revision [`338e4ed`](https://github.com/MinaFoundation/decentralised-treasury/commit/338e4edf69c567954ab880c8fe67d39d2b1ed82a), which opens a snapshot only once its `.done` marker exists, reopens it after a rebuild, retries failed events with backoff instead of blocking, and quarantines events that cannot be decoded. An event that is decoded but can never be handled is still retried indefinitely and blocks later events, but we found no way to produce such an event.

### The Proposal Projection Can Combine Conflicting or Duplicate Events

- **Severity**: Low
- **Location**: apps/api/src/processors/proposals/proposal-projection-reconciler.ts; packages/indexer/src/events-repository.ts

**Description**. The Proposal projection is built from all events that are `pending` or `canonical` (`isSurviving()`), but `assertCoherentProposalBranch()` only checks that the canonical ones form a single chain. The pinned Archive Node API (`0.0.9`) returns events from all pending branches whose tips are at the maximum height, and only picks the best block among the tips themselves. Two conflicting transactions on competing forks therefore both end up in the projection.

The indexer also identifies events by the Archive's database-local account update ID (`eventIdentity()`). If the Archive database is rebuilt, for example from precomputed blocks, these IDs change, and all events the indexer re-reads are stored a second time.

**Impact**. The web app can show a state that never existed on any chain, for example two pause toggles cancelling out, or two partial payouts from the same state counted together. Payouts that add up to more than a Proposal's limit make the processor fail, as described in [the finding on blocking the Proposal processor](#finding-low-13-transient-failure-blocks-processor).

**Recommendation**. Only project events of a single branch: canonical events, plus pending events that descend from the selected best tip. Identify events by transaction-relative data only.

**Client Response**. Fixed in revision [`338e4ed`](https://github.com/MinaFoundation/decentralised-treasury/commit/338e4edf69c567954ab880c8fe67d39d2b1ed82a), which only projects pending events on the ancestry of a unique best tip, falls back to canonical events when tips are tied, and identifies events by transaction hash, account update index and event index.

### Unbounded Relation Joins in the Public API Allow Cheap Database Denial of Service

- **Severity**: Low
- **Location**: apps/api/src/processor-crud-routes.ts

**Description**. The public processor API lets clients load related entities with `?join=` parameters. `appendJoinPath()` checks each segment of a join path against the declared relations, but limits neither the depth of a path nor the number of joins. Since Proposals and votes reference each other, a path like `votes.proposal.votes.proposal…` can be repeated indefinitely, and is turned into a single, very large SQL query.

**Impact**. Anyone can overload the database that the indexer, processor and API share. In our test, 24 requests of about 5 KB each slowed a normal Proposal list request from 38 ms to 13.7 seconds, for as long as the requests continued.

**Recommendation**. Limit join paths to one or two levels, limit the number of joins per request, and reject paths that revisit an entity.

**Client Response**. Fixed in revision [`7b4c919`](https://github.com/MinaFoundation/decentralised-treasury/commit/7b4c9199bba8b288f16734d8ee2eb15f88880952), which limits join paths to two levels and four relation steps per request, and rejects paths that revisit an entity.

### Some Voting Weight Can Never Be Used, Including the Treasury's Own Balance

- **Severity**: Informational
- **Location**: contracts/treasury-owner.ts; provable/staking-ledger-to-voting-ledger.ts

The voting ledger credits the MINA balance of every account to its delegate, and to vote, the delegate has to sign an AccountUpdate:

```typescript
AccountUpdate.createSigned(publicKey);
```

Weight whose delegate can't sign still counts towards the total currency that the participation thresholds are based on, but can never vote. A delegate can't sign if:

- its `access` permission requires a proof;
- nobody holds its private key, such as the burn address `B62qiburnzzzz…mp7r7UN6X`;
- its public key is not a valid curve point, like the two Mainnet accounts from [the invalid public key finding](#finding-medium-07-invalid-public-keys-block-mainnet-ledger).

The Treasury's own balance is also unlikely to vote. The Owner delegates to itself and can't change its delegate. With the default `access: proof`, it can't sign, and even with `proofOrSignature`, its operators are unlikely to vote with the Treasury's funds.

In the Mainnet staking ledger of September 24, 2026, 1.18% of the total currency can't vote, mostly 15.2 million MINA delegated to the burn address. With the Mina Foundation's working target of 55 million MINA in the Treasury, this would rise to 5.4%, and the 20% to 50% participation thresholds would correspond to 21.1% to 52.9% of the usable weight. Given how concentrated Mainnet delegation is, this does not come close to making Proposals impossible to pass.

**Client Response**. Acknowledged. The client documented the unusable voting weight in revision [`fbcd14a`](https://github.com/MinaFoundation/decentralised-treasury/commit/fbcd14af4f1899c51f7db84f7769e7fa6309dab6), and kept the protocol unchanged.

### Tallying Requires Votes in Five Distinct Slots

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

When tallying, `TreasuryOwner.tallyVotes()` checks the vote reducer proof against the Proposal's on-chain action-state history. The proof has to supply five action states, which must all be non-initial, pairwise distinct, and among the five states stored on the Proposal account. The uniqueness check fixes the finding [Action State Uniqueness Not Checked, Allowing Vote Discarding](https://reports.zksecurity.xyz/reports/mina-treasury-1#finding-high-action-state-uniqueness-not-checked) from our previous report.

A consequence is that a Proposal can only be tallied if its votes were dispatched in at least five distinct slots. Mina only shifts the action-state history when actions arrive in a new slot, so votes in the same slot produce a single new entry. If votes land in fewer than five slots, the history still contains the initial state, and the tally fails. Since no votes can be dispatched after the voting period, the Proposal can then never be approved, even if a single large voter already reached the participation threshold and majority.

Effectively, this acts as an additional, implicit participation threshold for accepting a Proposal, and is mentioned as such in the [protocol documentation](https://github.com/MinaFoundation/decentralised-treasury/blob/98f5ed25129a874dc36e217403f3114ce51b716e/apps/docs/docs/operate/reference/protocol-behavior.md?plain=1#L115-L117).

**Client Response**. Acknowledged. The client documented that votes must be included in at least five distinct slots in revision [`fbcd14a`](https://github.com/MinaFoundation/decentralised-treasury/commit/fbcd14af4f1899c51f7db84f7769e7fa6309dab6).

### CLI Tally Selects the Treasury Owner by Public Key Only, Ignoring the Token ID

- **Severity**: Informational
- **Location**: services/sqlite/sqlite-treasury-owner-service.ts

**Description**. To tally a Proposal, the CLI looks up the Owner account in the staking ledger and passes it with its Merkle witness to `tallyVotes()`. The lookup takes the first account with the Owner's public key:

```typescript
const treasuryOwnerIndex = stakingAccounts.findIndex((account) =>
  // ZKSECURITY: does not check account.tokenId
  account.pk.equals(treasuryOwnerPublicKey).toBoolean(),
);
```

Mina accounts are identified by public key and token ID, and anyone can create a custom-token account at any public key. An attacker who does this at the Owner's address before the Owner is deployed, for example by front-running the deployment transaction, gets an account that precedes the Owner in every later staking ledger. The CLI then selects this account, and the tally circuit rejects it since it requires the native-token Owner. Every tally through the CLI fails, until the lookup is fixed.

**Impact**. The attacker gains nothing and only causes an annoyance that is easy to fix in the client, so this is unlikely to happen in practice.

**Recommendation**. Select the Owner account by both public key and `TokenId.default`, as the API's `default-token-treasury-account.ts` already does.

**Client Response**. Fixed in revision [`f064b62`](https://github.com/MinaFoundation/decentralised-treasury/commit/f064b6206d700c7eded50fc300e5d415c97aa6f6) by selecting the Owner account by both public key and `TokenId.default`.

### Public-Facing Pods Carry More Kubernetes and Cloud Privileges Than They Need

- **Severity**: Informational
- **Location**: devops/runbooks/; MinaFoundation/helm-charts

**Description**. The `decentralized-treasury` Helm chart runs all Treasury pods under a single ServiceAccount, including the public API, web and proxy. This account can scale the proving workers, and the runbook suggests attaching the S3 role of the proof pipeline to it. The token can't be turned off for individual pods, and not even globally: the chart sets `automountServiceAccountToken: {{ .Values.serviceAccount.automount | default true }}`, and Helm's `default` treats `false` as unset.

In the `mina-staking-ledgers-provider` chart, the Nginx server that the Treasury proxy exposes publicly shares a pod with the ledger-fetch loop. Their common ServiceAccount can list pods and create `pods/exec` sessions in the whole `devnet` namespace.

**Impact**. An attacker who gains code execution in a public pod can stop or overscale the proving fleet and modify the proof pipeline's S3 data, or, through the ledger server, execute commands in any pod of the namespace. We did not find a way to get code execution in these pods.

**Recommendation**. Use separate ServiceAccounts for public and internal workloads, and bind the scaling permission and the S3 role only to the components that need them. Fix the `automount` template so that `false` takes effect, and turn it off for public pods. Split the ledger provider's fetch loop and Nginx server into separate pods, so that the public server has no token.

**Client Response**. Fixed in [helm-charts PR #346](https://github.com/MinaFoundation/helm-charts/pull/346), pinned in revision [`c00e8f8`](https://github.com/MinaFoundation/decentralised-treasury/commit/c00e8f82770140e43213b1b885e3e8d3149f12a0). Each Treasury workload now has its own ServiceAccount, only the proving scheduler can scale workers and mounts a token, cloud roles can no longer be shared, and the ledger provider's Nginx server runs in a separate pod without a token.

---

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