Aztec Labs

Aztec: PR 599

Cantina Security Report

Organization

@Aztec-Labs

Engagement Type

Cantina Solo

Period

-

Researchers


Findings

Low Risk

1 findings

0 fixed

1 acknowledged

Informational

2 findings

0 fixed

2 acknowledged

Gas Optimizations

1 findings

1 fixed

0 acknowledged


Low Risk1 finding

  1. Governance could bypass insider gate by lowering ATP registry timestamp when colluding with registry owner

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    r0bert


    Description

    relay() recomputes insiderCanActTimestamp() every call by reading ATP_REGISTRY.getExecuteAllowedAt() and adding seven days. The ATP registry owner, however, can decrease executeAllowedAt arbitrarily via Registry.setExecuteAllowedAt. If that owner cooperates with governance (or is compromised by it), they can:

    1. Lower executeAllowedAt for a single transaction so that insiderCanActTimestamp() is satisfied immediately.
    2. Execute relay() to drain funds or push approvals before insiders were genuinely allowed to act.

    This completely defeats the stated guarantee that insiders receive at least a week of participation time before governance can use the relayer. Any trust in the gate devolves into “trust the registry owner not to cheat,” which is the very scenario the relayer was meant to prevent.

    Recent design notes indicate the insider ATP registry is operated by a separate entity rather than governance. That setup prevents unilateral governance bypasses, but it still leaves a single mutable timestamp whose guardian can (intentionally or accidentally) invalidate the delay guarantees. If governance colludes with, compromises, or exerts pressure on that operator, the same three-step attack above becomes possible. Lowering executeAllowedAt also shortens insiders’ own “time-to-act,” so the timestamp remains a powerful lever that should not be mutable once insiders have deposited funds.

    Recommendation

    Decouple the relayer from future registry mutations. Either (a) pass the final unlock timestamp at deployment and store it immutably (adding the 7-day buffer internally), or (b) cache the first-observed executeAllowedAt and refuse to ever decrease the cached value, optionally only allowing increases. Additionally, consider transferring registry ownership to an independent contract that cannot collude with governance before insiders unlock.

    Aztec Labs: Acknowledged. The ATP registry owner (of the one where insider funds are) is not the governance, but a separate entity. Collusion could still create the issue though, but less likely. Moreover, it is not possible to restore the executeAllowedAt since it can only be decreased, e.g., if it is lowered it will stay low. Anyway, they could still allow the execution of a already queued proposal to execute as if it was possible for ATP's following that registry to act (without actually haven been able to).

    However, the insiders (ATP's following registry) have significant trust assumptions towards the registry owner already. As he will need to add an implementation that they can use to participate in governance for the period after executeAllowedAt until the assets are liquid. So if compromised, he could avoid them participating anyway by simply not adding a way to deposit.

    In short, it seems fine to me that we rely on the trust assumption for honest executeAllowedAt values, since we already rely on it being honest for more powerful actions.

Informational2 findings

  1. ProtocolTreasury inefective bootstrap on mature governance

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    r0bert


    Description

    markNext() only processes a single proposal per transaction and enforces that the proposal is already in a terminal state before incrementing markedProposalsCount. Because the counter is initialized to zero, deploying the relayer against an existing governance with N historical proposals requires N separate transactions, one for each proposal, to advance the pointer, since each invocation touches storage and the contract provides no batching primitive. For a live system that has already created thousands of proposals, this means thousands of keeper calls (e.g., 10000 proposals -> 10000 distinct markNext transactions) must be executed before the relayer can even consider forwarding a call. Until the backlog is cleared, relay always sees an unmarked, pre-insider proposal and reverts on the insider-check. Impact: onboarding ProtocolTreasury to a mature governance can entail a prohibitive amount of operational work and may be infeasible in practice if keepers cannot reliably submit that many transactions.

    Recommendation

    Add an initialization override so a trusted role can set markedProposalsCount, or extend markNext with a batched variant (markRange(uint256 count)) that iterates in-contract and emits events for the processed proposals while staying within block gas limits.

    Aztec Labs: Acknowledged. It is already possible to do a batch by using a multicall or similar, so we don't think that we need a markRange (though it was considered at first, but left out for simplicity). We don't think it is too much a concern in our setup because it is deployed along with the governance, so there are existing proposals and also how new proposals are created in our system. Adding some context, a governance proposal can be made in 2 ways:

    1. Locking a large amount for a significant time period (~2% of supply and locked for 90 days).
    2. Going through the "governance proposer" which is based on validator actions and at most one proposal can be made every 20 hours. So even a "spam" of proposals would grow very slow here and should have a higher cost to create than consume. Agree for a mature governance though that if there are a lot of proposals, but even then I think it should be no big hurdle.
  2. Relay strips governance caller identity

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    r0bert


    Description

    relay executes targets via a standard call originating from the relayer contract. Any contract that used to rely on msg.sender == GOVERNANCE will now see the ProtocolTreasury address instead. Once ownership or privileged roles are transferred to the relayer, the governance account no longer has those rights; the relayer becomes the effective admin. If downstream contracts still gate logic on the governance address and cannot be reconfigured to trust the relayer, those privileges are effectively lost when relaying.

    Recommendation

    Before routing calls through ProtocolTreasury, ensure all target contracts explicitly trust the ProtocolTreasury’s address (e.g., by transferring ownership to it or adding it to ACLs).

    Aztec Labs: Acknowledged.

Gas Optimizations1 finding

  1. Zero-value relays pay unnecessary BALANCE gas

    State

    Fixed

    PR #690

    Severity

    Severity: Gas optimization

    Submitted by

    r0bert


    Description

    relay always executes require(address(this).balance >= value, Errors.InsufficientBalance(...)). Even when value == 0, the contract performs a BALANCE opcode and allocates memory for the revert data, which costs ~400 gas per governance action, the common case if most relays don’t transfer native assets. Since a zero transfer can never underflow the relayer’s balance, the check can safely be skipped in that branch; alternatively, rely on the target call reverting naturally if balance is insufficient.

    Recommendation

    Wrap the balance check in if (value != 0) before executing it, so zero-value relays bypass the BALANCE opcode and associated memory work. This yields a small but recurring gas savings for governance executions that only call functions without native asset transfers.

    Aztec Labs: Fixed in PR #690.

    Cantina: Fix verified.