Table of Contents
Summary
- Type
- Precompile
- Timeline
- From 2025-03-03
- To 2025-03-11
- Languages
- Yul, Solidity
- Total Issues
- 5 (5 resolved)
- Critical Severity Issues
- 0 (0 resolved)
- High Severity Issues
- 0 (0 resolved)
- Medium Severity Issues
- 1 (1 resolved)
- Low Severity Issues
- 1 (1 resolved)
- Notes & Additional Information
- 3 (3 resolved)
Scope
We audited the pull request #1259 of the matter-labs/era-contracts repository at commit 886018a.
In scope were the following files:
system-contracts
└── contracts
├── Constants.sol
└── precompiles
├── EcAdd.yul
├── EcMul.yul
├── EcPairing.yul
└── Modexp.yul
The audited precompiles are system contracts run by the ZKsync VM and only deal with parsing the input parameters. The actual operations (see the "System Overview" section) are executed in a separate part of the execution layer that is written in Rust and is part of the VM itself. These components are out of scope and will be audited as part of a future audit.
System Overview
The four Yul files listed in the "Scope" section implement system contracts within the ZKsync VM that process the inputs and outputs for the following four operations: elliptic curve (EC) point addition (EcAdd.yul
), EC scalar multiplication (EcMul.yul
), EC pairing (EcPairing.yul
), and modular exponentiation (Modexp.yul
). These contracts are structured similarly and the main logic concerns the extraction of the respective inputs from the calldata, storing them in the correct format in memory, and passing them as input to the precompileCall
function. This function executes a generic opcode instruction in the VM that executes a precompile (ECAdd
, ECMul
, ECPairing
, Modexp
, among others) that is determined based on the calling contract.
Internally, precompileCall
calls the verbatim_2i_1o
function, which is intercepted by the VM to route the call to the execution layer. Specifically, the execute_precompile
function from the zksync-protocol
codebase (out of scope as stated above) is called, matching the call with its respective precompile implementation. Consequently, ecadd_function
, ecmul_function
, ecpairing_function
, or modexp_function
is triggered, respectively, performing the given operation and storing the result in memory. The result is then read back in the corresponding Yul contract.
The changes to the Constants.sol
file concern the addition of the address of the Modexp
system contract and the modification of some constants such as the number of blobs supported when submitting data to the L1.
Below, we provide details regarding the four operations mentioned above, along with their respective inputs and outputs as processed by the Yul contracts:
EcAdd
: Addition of two EC points with coordinates in affine representation(x1,y1)
and(x2,y2)
(four inputs), producing as a result a third point(x3,y3)
(two outputs).EcMul
: Multiplication of an EC pointP = (x1,y1)
by a scalark
(three inputs), producing as a result a third pointkP = (x2,y2)
(two outputs).EcPairing
: Ate pairing check over thealt_bn128
curve following the EIP-197 standard. In detail, the check verifies the equality e(A1,B1)∗⋯∗e(Ak,Bk)=1e(A_1, B_1) * \cdots * e(A_k, B_k) = 1e(A1,B1)∗⋯∗e(Ak,Bk)=1, where e:G1×G2↦GTe: G_1 \times G_2 \mapsto G_Te:G1×G2↦GT is the bilinear pairing operation, G1G_1G1 and G2G_2G2 are the two source groups, GTG_TGT is the target group, A1,⋯ ,AkA_1, \cdots, A_kA1,⋯,Ak is a list of kkk EC points in group G1G_1G1 and B1,⋯ ,BkB_1, \cdots, B_kB1,⋯,Bk is a list of kkk EC points in group G2G_2G2. The coordinates of each AiA_iAi point are elements of the field and so each AiA_iAi is defined with two field elements (xi,yi)(x_i,y_i)(xi,yi). The coordinates of each BiB_iBi point are elements of a quadratic extension of the field and so each BiB_iBi is defined with four field elements (xi1,xi2,yi1,yi2)(x_{i_1},x_{i_2},y_{i_1},y_{i_2})(xi1,xi2,yi1,yi2), where the two coordinates are represented with the pairs (xi1,xi2)(x_{i_1},x_{i_2})(xi1,xi2) and (yi1,yi2)(y_{i_1},y_{i_2})(yi1,yi2), respectively. For a single pairing (k=1k=1k=1), there are 666 inputs representing the points AAA and BBB together. In total, the contract takes 6k6k6k field elements as input (representing the coordinates of all AiA_iAi and BiB_iBi points) and reads a single Boolean output, depending on whether the pairing equality holds or not.Modexp
: Modular exponentiation operationr = b^e mod m
with baseb
, exponente
, and modulusm
(three inputs), producing as a result a scalarr
(one output).
As a final note, the reason for having dedicated contracts for the four operations listed above, as opposed to re-using the existing EVM precompile implementations, is that the ZKsync VM runs on an L2. The validity of its computation is proven by means of ZK proofs that are submitted to the L1 for verification. This makes it necessary to have provable implementations of all operations executed by the VM. That being said, the EcAdd
, EcMul
, EcPairing
, and Modexp
contracts should be functionally as close as possible to their EVM precompile counterparts (ecAdd
, ecMul
, ecPairing
, and modexp
, respectively). Users should, however, be aware of the following differences:
- The gas cost of calling each precompile is different from its EVM counterpart.
- The
Modexp
contract has a hardcoded limit on the lengths of the inputs (base, exponent, and modulus) that is set to 32 bytes. - The gas paid for the execution of the
EcPairing
precompile is capped at2^32 - 1
.
Security Model and Trust Assumptions
During the audit, the following trust assumptions were made:
- The gas costs for processing the four circuit precompiles accurately reflect the costs incurred by the rollup's operators and nodes.
- All four Yul contracts do not have a constructor, and it is assumed that these contracts will be pre-deployed at the right addresses with the correct runtime bytecode.
Medium Severity
Return Length of ‘EcPairing’ Does Not Match the Specifications
EIP-197 introduces the EcPairing
precompile on Ethereum and states that "The length of the returned data is always exactly 32 bytes and encoded as a 32 byte big-endian number". However, the fallback
function of the EcPairing
contract returns 64 bytes.
Consider returning only 32 bytes to avoid potential issues and more closely match the EVM's specifications.
Update: Resolved in pull request #1373 at commit fab789f. The return value has been updated to 32 bytes.
Low Severity
Hardcoded Modular Length Value in Return Statement
The modexp
precompile returns the last modLen
bytes of its first 32 bytes in memory. However, "32" is hardcoded.
Consider replacing the hardcoded "32" with MAX_MOD_BYTES_SUPPORTED()
to be consistent with the rest of the code and avoid potential errors if these values are ever updated.
Update: Resolved in pull request #1370 at commit e29c2be.
Notes & Additional Information
Gas Optimization
Prior to any computation, the ModExp
precompile cleans the first 3 words of memory. However, this memory should already be initialized to zero by the EVM as the precompile is only callable externally or by transactions.
Assuming that the above is also true on ZKsync, consider removing this check to save gas when the precompile is called.
Update: Resolved in pull request #1369 at commit de48942.
Missing or Misleading Documentation
Throughout the codebase, multiple instances where documentation could be improved were identified:
- This comment before the
return
statement of theModexp
precompile states that the returned result is "assumed to be right-padded with zeros". However, thesub(32, modLen)
offset suggests that the value is left-padded/right-aligned. - The
uint64_perPrecompileInterpreted
input tounsafePackPrecompileParams
is left-aligned, in contrast to the other four input words. This is due to thememoryPageToRead
andmemoryPageToWrite
arguments being left as 0, which could be documented. - The gas costs for all four precompiles are computed with respect to a value
80_000
that is not documented: ECADD_GAS_COST, ECMUL_GAS_COST, ECPAIRING_PAIR_GAS_COST, MODEXP_GAS_COST. - The distinction between EC pairing base and pair gas cost is not clear.
- There is a typo in the comment regarding the input length of the
unsafePackPrecompileParams
call inEcPairing.yul
: the second coordinate of the first point should bep_y
rather thanp_x
i.e.,(p_x, p_x, q_x_a, q_x_b, q_y_a, q_y_b)
should be(p_x, p_y, q_x_a, q_x_b, q_y_a, q_y_b)
.
Consider addressing the instances identified above to improve the readability and maintainability of the codebase.
Update: Resolved in pull request #1371 at commits 3acee2f and 96d51e2.
Modexp
Lacks an SPDX License Identifier
The Modexp.yul file lacks an SPDX license identifier.
To be consistent with the other precompiles and follow best practices, consider adding an SPDX license identifier to Modexp.yul
.
Update: Resolved in pull request #1372 at commit 553ea3a.
Recommendations
Differential Fuzzing
It has been challenging to test the code comprehensively in an end-to-end manner due to its use of a custom opcode called by a verbatim
and its reliance on a node running the ZKsync VM. As such, given the complexity of the implemented precompiles, we suggest differentially fuzzing them against Ethereum's precompiles to identify and address any potential edge cases.
Conclusion
The changes under audit introduce precompiles for elliptic curve (EC) point addition, EC scalar multiplication, EC pairing, and modular exponentiation as system contracts within the ZKsync VM. The code in scope handles the parsing of the inputs from calldata and packs them in a predefined format to be passed to the execution layer where the actual operation is performed. As mentioned in the introduction, this last part is out of scope and will be part of a future audit.
The audit revealed no major issues. We identified a deviation from the specifications and provided recommendations to improve the quality of the code. Overall, we found the implementation to be sound and well-documented, though we recommend adding differential tests against Ethereum's precompiles if possible. We thank the Matter Labs team for their detailed responses to all our questions.