We audited the ZKsync-Association/zk-governance repository at commit 2479575. All the resolutions mentioned in this report are contained at commit 29f0d0e.
In scope were the following files:
src
├── ZkGovOpsGovernor.sol
├── ZkProtocolGovernor.sol
├── ZkTokenGovernor.sol
├── extensions
│ ├── GovernorGuardianVeto.sol
│ └── GovernorSettableFixedQuorum.sol
└── lib
└── GovernorCountingFractional.sol
This audit covers a new L2 governance system intended to be deployed on the zkSync Era chain to govern several different aspects of the ecosystem. The scope mainly consists of three separate top-level governor contracts, namely ZkProtocolGovernor
, ZkTokenGovernor
, and ZkGovOpsGovernor
. All three governors are built on top of the OpenZeppelin Governor
module, utilizing pre-built extensions such as GovernorSettings
, GovernorVotes
, GovernorTimelockControl
, and GovernorPreventLateQuorum
.
In addition, the governors use a fractional vote counting mechanism via the custom GovernorCountingFractional
extension, allowing voters to split their vote weight among "Against"/"For"/"Abstain". There is an additional custom extension called GovernorSettableFixedQuorum
which allows the resetting of quorum via governance proposals. All governors are connected to a standard TimelockController
as the executor. Special proposer and canceller roles are introduced for some governors. These will be discussed in detail below.
ZkProtocolGovernor
ContractThis governor is intended to manage proposals related to protocol upgrades. Anyone with enough voting weight in the integrated token can propose a new proposal. A proposal can be canceled by the proposer before voting starts.
ZkTokenGovernor
ContractThis governor is intended to manage proposals related to the ZK token used for voting. It has a custom proposing and canceling mechanism:
PROPOSE_GUARDIAN
can propose with 0 threshold at any time. When the proposal guard is turned off, the usual proposing mechanism with the preset proposalThreshold
can be restored. This flag can be toggled on and off only via a governance proposal.VETO_GUARDIAN
address during either the active
or pending
state.ZkGovOpsGovernor
ContractThis governor is intended to manage proposals that are not covered by either ZkProtocolGovernor
or ZkTokenGovernor
. This governor has the usual proposing flow with a custom cancellation flow similar to that of ZkTokenGovernor
, that is, canceling a proposal can only be triggered by an immutable VETO_GUARDIAN
address during either the active
or pending
state.
Each governor allows proposals to change its own governor settings. These are the onlyGovernance
functions that can be executed via a successful governance proposal. In addition, all governors allow proposals with arbitrary data on arbitrary targets without any restriction. Thus, it is assumed that voters of each governor, as well as VETO_GUARDIAN
, carry the responsibility for the validation of each call for each governor based on up-to-date on-chain conditions.
PROPOSE_GUARDIAN
and VETO_GUARDIAN
The PROPOSE_GUARDIAN
can propose in the ZkTokenGovernor
with 0 threshold at any time and the VETO_GUARDIAN
can cancel a proposal during either the active
or pending
state.
TimelockController
as ExecutorAll governor contracts intend to integrate with a separate standard TimelockController
to be the _executor
. This allows the privileged roles in their respective TimelockController
to influence the governance process via the timelock. For instance:
PROPOSAL_ROLE
can schedule any proposal.CANCELLER_ROLE
can cancel any proposal, even successful proposals.EXECUTOR_ROLE
, if assigned to a non-zero account, can execute any proposal in a ready state.TIMELOCK_ADMIN_ROLE
can grant or revoke any of the above roles if assigned to a non-zero account.If self-administrating, additional accounts can be assigned to these roles upon successful proposals via the governance process. We assume that the accounts in charge of the above roles and actions always act in the intended way. Hence, any attacks or vulnerabilities targeting this part of the system were not considered throughout this audit.
castVoteWithReasonAndParamsBySig
Is Incompatible With ERC-4337ERC-4337 is a standard that allows smart contracts to behave like user accounts. Under this ERC, there will be ERC-4337-compliant smart contract accounts in addition to Externally Owned Accounts (EOAs). To verify the signature when casting a vote with reason and parameters, the GovernorCountingFractional.sol
contract uses the OpenZeppelin ECDSA
library. However, this library makes a call to the ecrecover
precompile contract which is incompatible with smart contract accounts.
Hence, to enable ERC-1271 smart contract account signature checks, consider using the SignatureChecker
library instead.
Update: Acknowledged, not resolved. The ScopeLift team stated:
An ERC-4337 wallet can use the non-signature methods to vote.
In GovernorCountingFractional
, the COUNTING_MODE
returns a string that specifies the key-value pair quorum=for,abstain
. This suggests that both For
and Abstain
votes should be counted towards quorum. However, when counting votes to decide whether quorum is reached, only the forVotes
are counted. An inconsistency between the results interpretation of the COUNTING_MODE
and the _quorumReached
functions could potentially lead to an incorrect UI representation that causes confusion about the proposal state.
Consider updating the counting mode to be consistent with the actual counting method.
Update: Resolved at commit a694730.
msg.sender
and _msgSender()
A contract may use the _msgSender()
function in certain cases where it allows meta transactions by overriding the method to extract the original message sender. Consistent use of _msgSender()/msg.sender
within a contract is important to avoid any unintended consequence for executing meta transactions.
In the following instances, both msg.sender
and _msgSender()
are used in the same contract:
msg.sender
in GovernorGuardianVeto
msg.sender
in ZkTokenGovernor
Consider manually checking for the consistent usage of msg.sender
, ensuring that any inconsistency is intentional and heavily documented.
Update: Resolved at commit 6afb121.
When a setter function does not check if the value has changed, it opens up the possibility of spamming events signalling that the value has changed whereas it has not. Spamming the same values can potentially confuse off-chain clients.
Within ZkTokenGovernor.sol
, the _setIsProposeGuarded
sets isProposeGuarded
and emits an event without checking whether the value has changed or not.
Although such event emission can only be triggered via a successful governance proposal, consider adding a check to emit an event only when the updated value differs from the current state.
Update: Acknowledged, not resolved. The ScopeLift team stated:
We are not implementing this fix. It is unlikely governance will pass a proposal with the same values and if they do it is not clear that clients or integrators should not be made aware.
Providing a specific security contact (such as an email or ENS name) within a smart contract significantly simplifies the process for individuals to communicate if they identify a vulnerability in the code. This practice is quite beneficial as it permits the code owners to dictate the communication channel for vulnerability disclosure, eliminating the risk of miscommunication or failure to report due to a lack of knowledge on how to do so. In addition, if the contract incorporates third-party libraries and a bug surfaces in those, it becomes easier for their maintainers to contact the appropriate person about the problem and provide mitigation instructions.
Throughout the codebase, there are contracts that do not have a security contact:
GovernorCountingFractional
abstract contract.GovernorGuardianVeto
abstract contract.GovernorSettableFixedQuorum
abstract contract.ZkGovOpsGovernor
contract.ZkProtocolGovernor
contract.ZkTokenGovernor
contract.Consider adding a NatSpec comment containing a security contact above each contract definition. Using the @custom:security-contact
convention is recommended as it has been adopted by the OpenZeppelin Wizard and the ethereum-lists.
Update: Resolved at commit 89af3fe and commit a8f1974.
Since Solidity 0.8.18, developers can utilize named parameters in mappings. This means mappings can take the form of mapping(KeyType KeyName? => ValueType ValueName?)
. This updated syntax provides a more transparent representation of a mapping's purpose.
Throughout the codebase, there are multiple mappings without named parameters:
_proposalVotes
state variable in the GovernorCountingFractional
contract_proposalVotersWeightCast
state variable in the GovernorCountingFractional
contractfractionalVoteNonce
state variable in the GovernorCountingFractional
contractConsider adding named parameters to mappings in order to improve the readability and maintainability of the codebase.
Update: Acknowledged, not resolved. The ScopeLift team stated:
This Note is related to the
GovernorCountingFractional.sol
contract, which was audited in the past. As such, we do not wish to modify or update this codebase.
Since Solidity version 0.8.4, custom errors provide a cleaner and more cost-efficient way to explain to users why an operation failed.
Multiple instances of revert
and/or require
statements were found within GovernorCountingFractional.sol
:
require
statement with the message "GovernorCountingFractional: no weight"revert
statement with the message "GovernorCountingFractional: all weight cast"require
statement with the message "GovernorCountingFractional: vote would exceed weight"revert
statement with the message "GovernorCountingFractional: invalid support value, must be included in VoteType enum"require
statement with the message "GovernorCountingFractional: invalid voteData"require
statement with the message "GovernorCountingFractional: vote would exceed weight"require
statement with the message "GovernorCountingFractional: invalid params for signature-based vote"require
statement with the message "GovernorCountingFractional: signature has already been used"For conciseness and gas savings, consider replacing require
and revert
statements with custom errors.
Update: Acknowledged, not resolved. The ScopeLift team stated:
This Note is related to the
GovernorCountingFractional.sol
contract, which was audited in the past. As such, we do not wish to modify or update this codebase.
Named return variables are a way to declare variables that are meant to be used within a function's body for the purpose of being returned as that function's output. They are an alternative to explicit in-line return
statements.
Within GovernorCountingFractional.sol
, there are unused named return variables:
againstVotes
return variable in the proposalVotes
functionforVotes
return variable in the proposalVotes
functionabstainVotes
return variable in the proposalVotes
functionConsider either using or removing any unused named return variables.
Update: Acknowledged, not resolved. The ScopeLift team stated:
This Note is related to the
GovernorCountingFractional.sol
contract, which was audited in the past. As such, we do not wish to modify or update this codebase.
Most of the codebase is very well-documented. However, the following aspect could benefit from clearer documentation.
There are three top-level governor contracts, namely ZkProtocolGovernor.sol
, ZkTokenGovernor.sol
, and ZkGovOpsGovernor.sol
, which are dedicated to processing non-overlapping categories of proposals. However, there is a lack of clear instructions or example proposals to inform proposers about which governor should one send a particular proposal to. In the current setup, if a proposal is sent to the wrong governor, it is through the voting or the cancelling process that the proposal can be stopped. As such, more instructive documentation could help minimize unnecessary voting and cancelling.
Consider expanding and improving the documentation pertaining to sending proposals to governors.
Update: Resolved at commits 6d62af4, 021e028, e1003cf1 and 4537681.
The L2 governance contracts provide ways to manage proposals that affect various aspects of the zkSync Era ecosystem, such as protocol upgrades, token minting and allocation, and other governance activities. This decentralized governance system lays down the infrastructure required to engage the community in protocol governance.
The audit yielded four low-severity issues along with a few recommendations for code improvement. The ScopeLift team, who developed the codebase, provided a detailed specification of the system. We also appreciate ScopeLift's team's valuable input in addressing our questions throughout this engagement.