OpenZeppelin Blog

zkSync Fee Model and Token Bridge Audit - OpenZeppelin blog

Written by OpenZeppelin Security | March 9, 2023

March 9, 2023

This security assessment was prepared by OpenZeppelin.

Table of Contents

Summary

Type
Layer 2
Timeline
From 2023-01-23
To 2023-02-17
Languages
Solidity
Total Issues
32 (22 resolved, 2 partially resolved)
Critical Severity Issues
0 (0 resolved)
High Severity Issues
1 (1 resolved)
Medium Severity Issues
5 (2 resolved)
Low Severity Issues
8 (7 resolved)
Notes & Additional Information
18 (12 resolved, 2 partially resolved)

Scope

We audited the matter-labs/zksync-2-contracts repository at the 9f3c6944e6320166edd96ef6586a9dd4548a27f2 commit. In scope were the following contracts:

├── ethereum
│   └── contracts
│       └── bridge
│           ├── L1ERC20Bridge.sol
│           └── interfaces
│               ├── IL1Bridge.sol
│               └── IL2Bridge.sol
└── zksync
    └── contracts
        └── bridge
            ├── L2ERC20Bridge.sol
            ├── L2StandardERC20.sol
            └── interfaces
                ├── IL1Bridge.sol
                ├── IL2Bridge.sol
                └── IL2StandardToken.sol

In addition, we audited the matter-labs/system-contracts repository at the 191246a878f1493a5ed5f0fa6d79af4ce3e7eb8f commit of pull request #170. The contracts marked as diff were previously audited and hence only the latest changes were reviewed. All other contracts were fully audited. In scope were the following contracts:

├── bootloader
│   └── bootloader.yul (diff)
└── contracts
    ├── AccountCodeStorage.sol
    ├── ContractDeployer.sol
    ├── ImmutableSimulator.sol
    ├── KnownCodesStorage.sol
    ├── L2EthToken.sol (diff)
    ├── MsgValueSimulator.sol (diff)
    ├── NonceHolder.sol
    └── interfaces
        ├── IAccountCodeStorage.sol
        ├── IContractDeployer.sol
        ├── IEthToken.sol (diff)
        ├── IImmutableSimulator.sol
        ├── IKnownCodesStorage.sol
        ├── IL2StandardToken.sol
        └── INonceHolder.sol

System Overview

This audit focuses on the latest changes to the bootloader, described in our previous audit, the functionality of the L1/L2 (Layer 1 / Layer 2) Bridges, and changes to the system contracts. We found the system to be modular and well-documented.

Summary of Changes to the Bootloader

The bootloader now supports the fee model, which is the main change over the previous version. For a given L1 gas price, the bootloader calculates the basefee for the zkSync L2. Users are generally required to make significant upfront payments when conducting zkSync transactions, and are later refunded for the unused ergs, the zkSync-equivalent gas unit. The fee includes an overhead amount of ergs, which compensates for the calculation of the proof as well as committing and verifying the block on L1. The bootloader is ensuring that users are not overcharged for their transactions by making these calculations itself and checking against the operator inputs. As such, the bootloader plays a crucial role in the fair processing of transactions. However, the operator can provide a higher refund to the user than what was calculated by the bootloader. In short, the bootloader calculates how much of the block’s overhead should be covered by each transaction. It also acts as an ETH bridge now, which was previously done through dedicated bridge contracts.

We identified one high-severity issue which allows a malicious operator to minimize the refund for the user, thereby effectively stealing funds. Further, we raised a medium-severity issue about instances of overflows and underflows as part of the fee calculation, one of which allows users to pay no fees.

L1/L2 Bridges

The new token bridge exclusively handles transfers of ERC-20 tokens between the two domains. It is worth noting that although it does not support ETH transfers, it is possible to send an arbitrary amount of ETH along with any token deposit.

All ERC-20 tokens are supported, as long as they are defined on L1. For every token deposited in the L1 bridge, a new token is minted on L2. The L2 token will copy the L1 token’s optional metadata (i.e. the name, symbol and decimals values) but it is otherwise a standard ERC-20 token. Any non-standard functionality (e.g., rebasing or transfer restrictions) is not replicated on the L2 token. When the token is returned to the L2 bridge, it is then burned and the corresponding L1 tokens are released.

System Contracts

The codebase defines some system contracts on L2 that help replicate (and extend) the EVM’s behavior in the zkEVM. Below is a summary of the system contracts that have been audited, with an overview of their respective functionality.

NonceHolder

The zkEVM supports native account abstraction, which means it recognizes some contracts as “accounts” that can originate transactions. To prevent replay attacks, the bootloader ensures that every transaction originating from an account has a unique nonce. It also ensures that account nonces are consumed sequentially (so all transactions have a well-defined order), although each account can optionally relax this restriction.

In addition, all contracts (including accounts) use a sequential deployment nonce to ensure every contract they deploy with the CREATE opcode has a unique address. If they use the CREATE2 opcode, the salt parameter should provide uniqueness, although the deployment nonce is incremented anyway so it still tracks the number of contracts that have been deployed.

The NonceHolder contract manages these nonces for all contracts and provides utility functions to ensure uniqueness.

KnownCodeStorage

To guarantee data availability, all L2 transactions are published on L1. In addition, the bytecode of all L2 contracts should also be published on L1. This ensures that the entire state of the zkEVM can always be reconstructed from publicly available information.

The KnownCodeStorage contract simply tracks which L2 contracts (technically, which bytecode hashes) have been published. To achieve this, L2 transactions can include optional bytecode hashes to signal to the bootloader that the preimages should be published. Any new values are marked off in the KnownCodeStorage contract once it guarantees that the preimage is known (either because the transaction originated on L1 or because the KnownCodeStorage explicitly sends a message to L1 that can only be resolved by publishing the preimage). It also ensures that any new bytecode conforms to the zkEVM length requirements.

AccountCodeStorage

The AccountCodeStorage contract stores the code hashes associated with all L2 contracts (not just accounts). This facilitates querying the code hash and code size of all contracts. In contrast to the EVM, L2 contracts don’t have separate initialization code, and instead include the constructor as part of their run-time bytecode. The AccountCodeStorage contract also manages the isConstructing flag associated with each contract to help recreate the expected constructor behavior (ie. it is called exactly once during deployment and never again).

ImmutableSimulator

Since the zkEVM does not distinguish between initialization code and run-time code, the contract’s code needs to be registered before the constructor is executed. This means that in contrast to the EVM, the constructor cannot modify the run-time code to include any immutable values.

Instead, the constructor code simply returns all of the immutable values, which are saved in the ImmutableSimulator contract for whenever they are needed.

ContractDeployer

Instead of using the CREATE and CREATE2 opcodes, contracts on the zkEVM are deployed using the ContractDeployer system contract. This contract manages the deployment nonces and ensures all new bytecode is known (see the KnownCodeStorage section).

It also distinguishes between “accounts” and regular contracts to ensure accounts can choose their account abstraction implementation (only version 1 is currently supported) and can manage their transaction nonce ordering.

Security Model and Privileged Roles

The security of the system in scope depends on the individual governors of the respective contracts, which are analyzed for the bridge and the system contracts.

The L1 ERC-20 bridge as well as the L2 ERC-20 token implementation are both upgradeable. Any ERC-20 token that is bridged into zkSync will be locked into this L1 bridge contract, and a malicious upgrade to this contract would let an attacker steal the locked funds. The L2 token implementation could be similarly affected (by stealing the tokens through a malicious version on L2 and then bridging them back to L1).

The L1 ERC-20 bridge implements the AllowList access control. This mechanism enables restriction for functions and callers. Hence, a function can be fully inaccessible, or only accessible to specific addresses. The finalizeWithdrawal function is affected by this mechanism, which is a necessary step to withdraw ERC-20 tokens from L2 to L1. Thus, users could be prevented from bridging their assets back to L1.

The system contracts in scope play an integral role in the system’s operations. These contracts can be upgraded through a force-deploy through a requested L2 transaction from L1. This upgrade mechanism is tied to proposals made to the DiamondCut facet and managed by this respective governor and the security council.

It is strongly advised that the governors and owners of these contracts be extensively secured through cold wallets and multisigs. Further, it is assumed that these actors will act in the best interest of the users.

The zkSync system requires a dedicated operator role to coordinate and bundle all L2 transactions. Currently, the operator will be centralized through Matter Labs with the goal to decentralize it in the medium-term future. The operator has the privilege to include or exclude any transaction they choose. The priority mode censoring protection mechanism is still to be implemented. Furthermore, as part of the fee model at the current stage of the protocol, the operator is trusted to pick a fair L1 gas price, which influences the L2 fees paid by the user.

EVM Differences

It is worth noting that due to the current system design, some interactions work differently from the EVM. For instance, to deploy a contract or custom account, a user must call the respective system contract along with specific parameters, which is not the case for an EVM deployment transaction. Prior to that, the bytecodes (factory dependencies) need to be explicitly shared with the operator and published on L1. The zkSync documentation provides additional context.

In addition, as seen in the Potential EIP-1052 Deviation issue, it is possible that opcodes deviate from EVM behavior, which still needs to be explored for other opcodes. For more differences – including unsupported opcodes – visit the EVM compatibility section of the zkSync documentation.

Testing Coverage Recommendations

Due to the complex nature of the system and several subtle deviations from the EVM, we believe this audit would have benefitted from more complete testing coverage, particularly around the bridge messages and new fee mechanics.

While insufficient testing is not necessarily a vulnerability, it implies a high probability of additional hidden vulnerabilities and bugs. Given the complexity of this codebase and the numerous interrelated risk factors, this probability is further increased. Testing provides a full implicit specification along with the exact expected behaviors of the codebase, which is especially important when adding novel functionalities. A lack thereof increases the chances that correctness issues will be missed. It also results in more effort to establish basic correctness and reduces the effort spent exploring edge cases, thereby increasing the chances of missing complex issues.

Moreover, the lack of repeated automated testing of the full specification increases the chances of introducing breaking changes and new vulnerabilities. This applies to both previously audited code and future changes to current code. This is particularly true in this project due to the pace, extent, and complexity of ongoing and planned changes across all parts of the stack (L1, L2, bootloader and system contracts, compiler and zkEVM). Underspecified interfaces and assumptions increase the risk of subtle integration issues, which testing could reduce by enforcing an exhaustive specification.

To address these issues, we recommend implementing a comprehensive multi-level test suite before the next expected audits. Such a test suite should comprise contract-level tests with >90% coverage, per-layer deployment and integration tests that test the deployment scripts as well as the system as a whole, per-layer fork tests for planned upgrades, and cross-chain full integration tests of the entire system. Crucially, the test suite should be documented in a way so that a reviewer can set up and run all these test layers independently of the development team. Some existing examples of such setups can be suggested for use as reference in a follow-up conversation. Implementing such a test suite should be a very high priority to ensure the system’s robustness and reduce the risk of vulnerabilities and bugs.

High Severity

Malicious Operator Can Dodge Refund

In the refundCurrentL2Transaction function, the user gets refunded for overpaying in ergs for their L2 transaction. The ETH amount to refund is dependent on the ergs amount and the ergs price. The ergs amount is the maximum value of the operator-provided value and the one calculated by the bootloader. The ergs amount multiplied by the ergs price determines the refund in ETH.

A malicious operator can provide a very large amount of ergs that should be refunded, which will therefore be the chosen refundInErgs value. However, when multiplied by the ergs price, ethToRefund can overflow and lead to a very low refund, effectively stealing funds from the user.

Consider calculating the refund based on the bootloader and operator values individually and then picking the higher amount to protect the fee model against a malicious operator.

Update: Resolved in pull request #208 at commit 57ba2b1.

Medium Severity

Users Can Lose Funds in L1ERC20Bridge Implementation Contract

The L1ERC20Bridge is the implementation contract that is intended to be used with a proxy, so it is good practice to restrict how it can be invoked directly. When the contract is constructed, the reentrancyGuardInitializer modifier is executed and the immutable _mailbox address is written to the bytecode that is deployed. During this invocation of reentrancyGuardInitializer, the reentrancy status is set to _NOT_ENTERED, which locks the initialize function from being called, but simultaneously allows functions with the nonReentrant modifier to be called.

Specifically, the deposit function of the implementation contract is callable. However, in the implementation contract itself, many variables are not initialized, such as the l2Bridge variable, so it holds the zero-address. Therefore, the deposit function would request an L2 transaction that attempts to finalize the withdrawal by calling the zero-address, thereby triggering the non-reverting fallback function of the EmptyContract. Since this L2 call does not fail, the deposited tokens are locked and irrecoverable, as a call to claimFailedDeposit cannot be proven.

Consider implementing a stricter mechanism that prohibits direct calls to the contract if all or some of its variables were not properly initialized. In addition, consider preventing the initialization of the implementation contract more directly, rather than relying on the implicit behavior of reentrancyGuardInitializer, which lacks visibility.

Update: Acknowledged, not resolved. The Matter Labs team stated:

While we appreciate your insights and suggestions, we do not believe the issue carries a significant security risk. As the contract is intended to be used through a proxy, direct calls to the implementation contract are not recommended. Users could also call any other scam contract.

Lack of __gap Variable

The L1ERC20Bridge and L2StandardERC20 contract are intended to be used as logic contracts with a proxy, but do not have a __gap variable. This would become problematic if a subsequent version was to inherit one of these contracts. If the derived version were to have storage variables itself and additional storage variables were subsequently added to the inherited contract, a storage collision would occur.

Consider appending a __gap variable as the last storage variable to these upgradeable contracts, such that the storage slots sum up to a fixed amount (e.g. 50). This will proof any future storage layout changes to the base contract. Note that the __gap variable space will need to be adjusted accordingly as subsequent versions include more storage variables, in order to maintain the fixed amount of slots (e.g. 50).

Update: Acknowledged, not resolved. The Matter Labs team stated:

While we appreciate your insights and suggestions, we do not believe the issue has a significant security risk. Specified contracts are not expected to be inherited, since they are complete logical contracts.

ceilDiv Function Can Overflow

The ceilDiv function of the bootloader is used throughout the fee model formulas. This function can overflow as the numerator and denominator are added together before they are divided. Although this was not identified as a threat in the current codebase, an issue could be introduced by using this function with unvalidated inputs.

Consider adapting the formula to be safe from overflows.

Update: Resolved in pull request #236 at commit 56b3231.

Overflows in Fee Computation

The bootloader changes in scope involve a few formulas as part of the fee model, and there were a few instances where calculations were performed on user or operator-provided inputs. Unchecked arithmetic (without overflow protection) using these values is generally dangerous and prone to exploits.

One example is the calculated ergs price. If the maxPriorityFeePerErg is sufficiently large and the maxFeePerErg value is increased to match, the maxFeeThatOperatorCouldTake calculation could overflow, resulting in a zero ergs price. The transaction will still be executed but the amount the user pays would be zero. A savvy operator could recognize this scenario and discard the operation, but it does make the system unnecessarily fragile.

We also identified the following cases where potential overflows with user-provided values appear to be unmitigated, although the consequences are limited:

Lastly, the following functions appear to allow overflows, but they are protected by validations in other parts of the codebase:

Consider applying additional checks to these operations, explicitly documenting where the checks are or why they would not be necessary. Whenever a potential overflow is not mitigated in the same function, consider documenting where the relevant validation can be found. It is advised to check the rest of the bootloader for more potential overflows and underflows. Lastly, it is recommended to validate all changes with proper dynamic testing.

Update: Resolved in pull request #211 at commit 448932e.

Missing Factory Dependencies

When the L1ERC20Bridge is initialized, the bytecode of the L2 bridge and token proxy are both provided as factory dependencies. This ensures the code is known when the L2 Bridge is deployed, or a new token is created. However, the L2 bridge initialization also deploys the token implementation and proxy beacon. Since neither contract was listed as a factory dependency, they may not be marked as known. Unless they were previously mentioned in an unrelated transaction, the bridge initialization will fail.

Consider including the L2StandardERC20 and the UpgradeableBeacon contracts in the factory dependencies of the bridge initialization transaction.

Update: Acknowledged, will resolve. The Matter Labs team stated:

Acknowledged. The problem can only be encountered at the initialization stage, so we prefer not to change the deployment scripts at the moment. This has been included in the backlog as a refactoring task.

Low Severity

Missing Error Messages in require and revert Statements

Throughout the bridge and system contracts codebases there are require and revert statements that lack error messages:

Consider including specific, informative error messages in require and revert statements to improve overall code clarity and facilitate troubleshooting whenever a requirement is not satisfied. In addition, for statements that do contain a message, consider using longer strings instead of a few characters to describe the error.

Update: Resolved in pull request #212 at commit cb93637 and pull request #63 at commit 77e64b5. The Matter Labs team stated:

We did not add error messages to reverts from name/symbol/decimals functions because if the token does not implement that method it should behave exactly the same way as if the function was not declared.

L2ERC20Bridge Is Not Upgradeable

The L1ERC20Bridge and L2ERC20Bridge contracts manage the ERC-20 token bridge for the Layer 1 and Layer 2 sides respectively. These two bridge contracts are intertwined, as L1 tokens are locked into the L1ERC20Bridge in order to mint the corresponding L2 tokens. Currently, these L1 tokens can only be unlocked in the event of a withdrawal that is initialized through L2ERC20Bridge. In the event of an undiscovered bug in the L2 bridge, the only way to upgrade it would be to redeploy a new L1 bridge (since it is not upgradeable).

The new L1ERC20Bridge contract would then be intertwined with a new L2ERC20Bridge. One drawback of this approach is the inconvenience of re-deploying a new L1 contract every time there is a desire to change the L2 contract. In addition, each ERC-20 token minted on a different L2 bridge would generate a new L2 address, creating multiple copies of the same L1 token in L2. Consider making the L2ERC20Bridge upgradable by making it an implementation contract behind a proxy. However, note that even without this upgradeability of the L2 bridge, the upgradeability of the L1 bridge would still prevent L1 tokens from being locked forever.

Update: Resolved at commit b51d4c3.

Potential EIP-1052 Deviation

On zkSync, the EXTCODEHASH opcode is realized by using the getCodeHash function of the AccountCodeStorage contract. This returns the registered bytecode hashes for deployed contracts. The EIP-1052 standard dictates that the return of precompile contracts should be either 0 or c5d246... (the hash of the empty string). However, precompile code hashes can differ from those values by setting them to their real code hash or to an arbitrary value during a forced deployment, thereby causing an inconsistency with EIP-1052.

Consider conforming to EIP-1052 by adding additional checks to ensure that the precompile contracts return either of the values defined in the specification.

Update: Resolved in pull request #213 at commit 98cb968. The Matter Labs team stated:

We also added the correct codehash/codesize for the zero-address.

Lack of Events

In ContractDeployer.sol, there is no event emitted when an AccountAbstractionVersion or an [AccountNonceOrdering] (https://github.com/matter-labs/system-contracts/blob/191246a878f1493a5ed5f0fa6d79af4ce3e7eb8f/contracts/ContractDeployer.sol#L81) are updated, which makes it challenging to monitor whether an account has updated either of these values.

In addition, the DepositInitiated event should emit the L2 transaction hash, because it may be needed to claim a failed deposit.

Consider emitting and updating these events to facilitate monitoring.

Update: Resolved in pull request #214 at commit d398cce and pull request #63 at commit d0ce4d4.

setValueUnderNonce Value Is Mutable

In the NonceHolder contract, the setValueUnderNonce function sets a specific _value associated with a particular _key (nonce). While setting it to 1 would be enough to simply convey if a specific nonce has already been used, it is actually a uint256 value. According to the internal documentation shared with us, it “could be used to store some valuable information.” This could correspond to details about the particular transaction that consumed this nonce (e.g., the amount of wei that was sent in the transaction).

While a user cannot reset the _value to 0, they can change it to any other non-zero quantity. This can be misleading for external entities that may be relying on it for particular information about the transaction. More importantly, since no event is emitted, an external party may not know that it has been changed.

Consider requiring that the _value for a particular _key is settable only once. Alternatively, if it is intended to be mutable, consider emitting an event every time it is set.

Update: Resolved in pull request #215 at commit d6fd17d. The Matter Labs team stated:

It is mutable in case users want to store some valuable data under the respective nonce. For example, a user might want to store a mapping such as (uniqueNonce => someInternalConstant).

Implicit Zero Cost Assumption

The L1ERC20Bridge contract will deploy the Layer 2 token bridge by passing a request to the Mailbox. However, it will not send any ETH. This is acceptable while the stub fee calculation uses a zero fee, but when the calculation is updated, messages that do not pay fees will be rejected.

In the interest of predictability, consider allowing ETH to be sent with the L2 bridge deployment request.

Update: Resolved in pull request #65 at commit 15b3433.

Floating Pragma Solidity Version

Throughout the bridge and the system contracts codebases, the version of Solidity used is ^0.8.0. This version indicates that any compiler version after 0.8.0 can be used to compile the source code. However, the code will not compile when using version 0.8.7 or earlier since there are functions which override interface functions without using the keyword override. This is only permitted in Solidity 0.8.8 and above. In addition, there is the usage of abi.encodeCall, which was not introduced until Solidity 0.8.11. However, it is recommended to use Solidity 0.8.13 or above since there was a bug detected regarding fixed-length bytes literals. While this bug does not currently affect the codebase, using an updated version will remove the possibility of future errors.

Consider upgrading all contracts to Solidity version 0.8.13 at a minimum, but ideally to the latest version.

Update: Resolved in pull request #66 at commit e422c7f. Regarding the floating point Solidity version, the Matter Labs team stated:

System contracts are exposing the System API to smart contract developers, so the code they write will have to adhere to the same compiler version’s limitations. Similarly to OpenZeppelin Smart Contracts libraries, our System Contracts enforce the minimum Solidity version to ensure that they can be properly built, but do not pin a specific version, so we do not prevent the developers from using newer features.

Unnecessarily Delayed Error Handling

When depositing ERC-20 tokens, the L1ERC20Bridge contract queries the token’s metadata, and sends the results to the L2ERC20Bridge contract. Any errors are still encoded on L1 and discarded on the L2 bridge. This pattern seems unnecessarily complex, and couples the processing on both sides. Consider detecting errors on L1 and only sending the relevant values over the bridge.

Update: Acknowledged, will resolve. The Matter Labs team stated:

We will take this change into account when further refactoring is done.

Notes & Additional Information

Interface Mismatch

The isNonceUsed function is defined in the NonceHolder contract, but it is missing in the INonceHolder interface.

Consider aligning the interface with the contract to fully represent its features.

Update: Resolved in pull request #216 at commit c9f1e3c.

Variable Visibility Not Explicitly Declared

Throughout the bridge and system contracts codebases, there are state variables, constants, and immutables that lack an explicitly declared visibility:

For clarity, consider always explicitly declaring the visibility of variables, even when the default visibility matches the intended visibility.

Update: Acknowledged, will resolve. The Matter Labs team stated:

We will take this change into account when further refactoring is done.

Code Redundancy

There are two instances in the codebase where redundant computations are performed:

  • In the ContractDeployer contract, there is some redundancy in the logic of the create{2}{Account} functions. Consider highlighting their differences and reusing existing logic by having one function call the other.
  • In the getErgsLimitForTx function of the bootloader, the ergsLimitForTx value is computed a second time after being computed as part of the getVerifiedOperatorOverheadForTx function. This particularly hinders the visibility of the underflow check.

Consider applying the above changes to improve the code’s clarity.

Update: Resolved in pull request #218 at commit 5828dc1.

Inexplicit Fail

In the L2EthToken contract, if the user attempts to transfer more funds than owned, the function reverts with a Solidity underflow error. This can result in a confusing user experience. Consider explicitly checking this condition and giving the user a proper revert reason if the transfer fails.

Update: Resolved in pull request #217 at commit d4e019f.

Indecisive License

Throughout the codebase there are several files that state an SPDX license identifier of “MIT OR Apache-2.0”.

Consider agreeing on one license per file to prevent confusion on how these files can be used.

Update: Resolved in pull request #226 at commit d7c89a5.

Misplaced Event

The L2StandardERC20 contract has the BridgeInitialization event defined in the contract, while its other events BridgeMint and BridgeBurn are defined in the IL2StandardToken interface.

Consider defining events in the same place for better visibility.

Update: Resolved in pull request #67 at commit 1de79d9.

Gas Optimizations

The following opportunities for gas optimizations were identified:

  • In the L2StandardERC20 contract, the bridgeInitialize function checks that the l1Address is not the zero address. This is to guarantee that the contract has not been initialized before. However, the contract already has the initializer modifier, so the require statement is redundant.
  • The l2TokenProxyBytecodeHash variable can be made immutable since it is only set once in the constructor and there is no functionality to change it. The purpose of this variable is solely to determine the address of the L2 Token.
  • In the _splitRawNonce function a division by a power of two is made. Instead, a right shift by the exponent would be cheaper.

Consider making the above changes to reduce gas consumption.

Update: Partially resolved in pull request #68 at commit b37a19a. The Matter Labs team stated:

We did not implement the second and third suggestions, since immutable is more expensive in L2 contracts (see ImmutableSimulator contract) and the arithmetic operations are well-optimized by our compiler (LLVM backend!).

Inexplicit Disable of Initialization

The L2StandardERC20 contract is used as the token logic contract for the bridge. Hence, each token is a beacon proxy instance which refers to the logic of the L2StandardERC20 contract, which is initializable. The actual logic contract is initialized by having the initializer modifier on the constructor, to prevent an attacker from initializing it maliciously.

Instead of using the modifier, consider calling the _disableInitializers function of the Initializable contract explicitly in favor of readability and clarity.

Update: Resolved in pull request #69 at commit 5db383b.

Inconsistent Usage of Named Return Variables

In the L1ERC20Bridge contract, while most functions use named return variables, the _depositFunds and l2TokenAddress functions have a return statement instead. On the other hand, the L2ERC20Bridge contract mostly uses unnamed return statements, except for the _getCreate2Salt function. In the NonceHolder contract, there are both a named return variable and a return statement in the getDeploymentNonce function.

Consider applying one return style for better readability.

Update: Acknowledged, not resolved. The Matter Labs team stated:

It makes sense to follow one style standard, but in practice, we see that different return methods are convenient in different cases, so for now we will stay with what we have. However, we will rethink this when we begin refactoring the codebase.

Inconsistent Declaration of Integers

In the bootloader contract, there is an inconsistency in the way memory offsets are declared, with some being expressed in decimals and most others being expressed in hexadecimals. This deviation from a consistent notation can be confusing and make it difficult to understand the purpose and usage of these sizes. For instance:

Consider using a consistent notation for expressing memory offsets throughout the codebase.

Update: Partially resolved in pull request #219 at commit c022f89. There are more instances similar to the examples mentioned above that can be changed with further refactoring.

Naming Suggestions

To favor explicitness and readability, there are several locations in the contracts that may benefit from better naming:

Update: Resolved in pull request #220 at commit d5bf7da and pull request #70 at commit 0cb750b.

Unused Imports

Throughout the codebase imports on the following lines are unused and could be removed:

Consider removing unused imports to avoid confusion that could reduce the overall clarity and readability of the codebase.

Update: Resolved in pull request #221 at commit 49dbcf5 and pull request #71 at commit 53beb18.

Theoretical Aggregate ETH Inconsistency

The forceDeployOnAddresses function attempts to send the specified amount of L2 ETH with each deployment, after collecting the aggregate amount. However, the value sent with each call is implicitly truncated to the least significant 128 bits (henceforth, “lower half”). This means the most significant 128 bits (“upper half”) of every value parameter is ignored, which has two implications:

  • Any deployment that attempts to send more than 2128 Wei will only send the lower half of the amount, leaving the rest in the ContractDeployer contract.
  • The FORCE_DEPLOYER address can manipulate the upper half of the aggregate amount by setting large values that will be truncated. It can also cause the result to overflow, since the summation occurs inside an unchecked block. If the actual aggregated amount exceeds 2128 Wei and the FORCE_DEPLOYER address manipulates the summation to clear the upper half, they will only need to pay for the lower half of the amount. The rest will come out of the ContractDeployer contract’s own balance.

In practice, the total supply will be less than 2128 for the foreseeable future, so neither scenario should be possible. In addition, the FORCE_DEPLOYER is a trusted address that would not be expected to manipulate the deployment configurations. Nevertheless, all assumptions should be enforced wherever possible. In the interest of predictability and local reasoning, consider restricting the ForceDeployment.value parameter to 128 bits. Alternatively, consider requiring the msg.value to be less than 2128 Wei instead of truncating it, which would remove the possible inconsistency for all use cases.

Update: Resolved in pull request #222 at commit 9600e74. The Matter Labs team stated:

The msg.value is not implicitly truncated (if so we would have a larger issue). The compiler checks if the value is less than 2^128, and panics otherwise. The sumOfValues overflow was resolved.

Checks-Effects-Interactions Recommendation

The deposit function of the L1ERC20Bridge contract interacts with an untrusted token before updating its own state. Although it is protected by the nonReentrant modifier and there are no reentrancy possibilities in the current codebase, as a matter of good practice, consider moving the token transfer to the end of the function to follow the checks-effects-interactions pattern.

Update: Acknowledge, not resolved. The Matter Labs team stated:

We agree that in general it is more appropriate to use patterns wherever possible. However, in our case, to follow the checks-effects-interactions we would need to remove the calculation of the amount of deposited money, as the balance differs before and after. On other hand, it is difficult for us to imagine a reentrancy attack on this contract and we are already defending against this possibility with a nonReentrant modifier. As a result, we decided not to implement the fix.

Complex Nonce Packing

The NonceHolder contract uses a single storage slot to pack each account’s deployment nonce and minimum transaction nonce.

Instead of manually splitting and recombining them, consider defining a struct that holds them both. That struct can then be saved in the rawNonces mapping and the individual nonces can be accessed and updated more easily.

Update: Acknowledged, will resolve. The Matter Labs team stated:

We will take this change into account when further refactoring is done.

Typographical Errors

Throughout the codebase we identified the following typographical errors:

Consider correcting these typographical errors.

Update: Resolved in pull request #223 at commit 7042d1f, pull request #228 at commit 5398a91, and pull request #72 at commit f0c0544.

Unclean Code

The line that extracts the isSystemCall flag has two sets of redundant brackets that do not clarify the order of operations.

Consider grouping the bit AND operation before the logical != operation, or removing the brackets entirely.

Update: Resolved in pull request #224 at commit ad13199.

Missing and Misleading Documentation

The following parts of the codebase are lacking documentation:

The following documentation is misleading:

Consider adding more documentation to the codebase to enhance its clarity. In addition, consider rephrasing misleading comments to match the intention of the code.

Update: Resolved in pull request #225 at commit 3a28b44, pull request #218 at commit 5828dc1, and pull request #73 at commit 275d73e.

 

Conclusions

This audit was conducted over the course of 4 weeks. Through an in-depth review of the new fee model, we uncovered a high-severity issue and additional attack surfaces. We also identified several medium and lower-severity issues in the system and the bridging contracts. The Matter Labs team provided us with dedicated documentation again, which was very helpful to understand this complex system.

 

Appendix

Monitoring Recommendations

While audits help in identifying code-level issues in the current implementation and potentially the code deployed in production, we encourage the Matter Labs team to consider incorporating monitoring activities in the production environment. Ongoing monitoring of deployed contracts helps in identifying potential threats and issues affecting the production environment. Hence, with the goal of providing a complete security assessment, we want to raise several actions addressing trust assumptions and out-of-scope components that can benefit from on-chain monitoring.

Governance

Critical: There are multiple privileged actions with serious security implications:

  • The owner of the AllowList contract controls access to the token bridge functionality, and has the ability to disable withdrawals.
  • The various bridge contracts can be upgraded arbitrarily, which implies the power to steal all funds on layer 2.
  • The FORCE_DEPLOYER address can upgrade any of the system contracts to radically change the behavior of the zkEVM.

Consider monitoring triggers of these administrator functions to ensure all changes are expected.

Financial

Medium: Consider monitoring the size, cadence and token type of bridge transfers during normal operations to establish a baseline of healthy properties. Any large deviation, such as an unexpectedly large withdrawal, may indicate unusual behavior of the contracts or an ongoing attack.

Technical

High: All L1 deposits should correspond to tokens minted on L2. Similarly, all tokens burned on L2 should correspond to released tokens on L1. This means every L1 token’s bridge balance should match the corresponding L2 token’s total supply, with some tolerance to account for the time delay of bridge transfers. Consider monitoring that these two values remain acceptably close to each other (for example, within 5%).

Suspicious Activity

Low: Withdrawals on L1 are not triggered automatically, and must instead be initiated by L1 users. Any withdrawal that takes too long to finalize may indicate a problem with the withdrawal proof. Consider monitoring for withdrawals that remain in transit for significantly longer than the median withdrawal duration.