Platform: Code4rena
Start Date: 02/10/2023
Pot Size: $1,100,000 USDC
Total HM: 28
Participants: 64
Period: 21 days
Judge: GalloDaSballo
Total Solo HM: 13
Id: 292
League: ETH
Rank: 22/64
Findings: 3
Award: $3,484.61
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: zero-idea
Also found by: 0x1337, 0xTheC0der, 0xstochasticparrot, Audittens, HE1M, Jeiwan, erebus, gumgumzum, leviticus1129, lsaudit, quarkslab, rvierdiiev
656.3255 USDC - $656.33
The CURRENT_MAX_PRECOMPILE_ADDRESS
is defined as current maximum deployed precompile address.
According to its NatSpec: Important! So the constant should be updated if more precompiles are deployed.
CURRENT_MAX_PRECOMPILE_ADDRESS
is being utilized in system-contracts/contracts/AccountCodeStorage.sol
to simulate the behavior of the extcodehash
and extcodesize
EVM opcode (functions getCodeHash()
and getCodeSize()
.
Whenever CURRENT_MAX_PRECOMPILE_ADDRESS
is not correctly defined, those function won't behave as expected and the whole AccountCodeStorage
won't return expected results.
When we look at the CURRENT_MAX_PRECOMPILE_ADDRESS
, it's defined as: uint256 constant CURRENT_MAX_PRECOMPILE_ADDRESS = uint256(uint160(SHA256_SYSTEM_CONTRACT));
. It assumes that there are only two precompiled contracts and the SHA256_SYSTEM_CONTRACT
is the last one.
However, there are 4 precompiled contracts, the last one is ECMUL_SYSTEM_CONTRACT
:
File: system-contracts/contracts/Constants.sol
address constant ECRECOVER_SYSTEM_CONTRACT = address(0x01); address constant SHA256_SYSTEM_CONTRACT = address(0x02); address constant ECADD_SYSTEM_CONTRACT = address(0x06); address constant ECMUL_SYSTEM_CONTRACT = address(0x07);
This implies, that CURRENT_MAX_PRECOMPILE_ADDRESS
wasn't updated when ECADD_SYSTEM_CONTRACT
and ECMUL_SYSTEM_CONTRACT
were added, thus its value is incorrect.
It ignores two additional precompiled contracts.
Manual code review
Change uint256 constant CURRENT_MAX_PRECOMPILE_ADDRESS = uint256(uint160(SHA256_SYSTEM_CONTRACT));
to uint256 constant CURRENT_MAX_PRECOMPILE_ADDRESS = uint256(uint160(ECMUL_SYSTEM_CONTRACT));
Other
#0 - c4-pre-sort
2023-10-31T06:51:22Z
bytes032 marked the issue as duplicate of #142
#1 - c4-judge
2023-11-23T19:31:09Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: erebus
Also found by: 0xTheC0der, Bauchibred, HE1M, Jorgect, Udsen, alexfilippov314, chaduke, evmboi32, hash, ladboy233, lsaudit, oakcobalt, rvierdiiev, ustas, wangxx2026, xuwinnie, zero-idea, zkrunner
883.9114 USDC - $883.91
unchecked
block may overflow in NonceHolder.sol
File: system-contracts/contracts/NonceHolder.sol
unchecked { rawNonces[addressAsKey] = (oldRawNonce + _value); }
It's important to notice that parameter _value
is passed by the user. This means, that calculating oldRawNonce + _value
in unchecked
block is not safe - since this value may overflow the new nonce.
The function is public, but has onlySystemCall
modifier - thus the severity had been decreased to QA only. Nonetheless, performing calculations in unchecked
blocks, where one of the argument is taken from the function's parameters is bad security practice and should be avoided.
The only protection from overflow is at line 66: require(_value <= MAXIMAL_MIN_NONCE_INCREMENT, "The value for incrementing the nonce is too high");
This requires, that _value
cannot exceed MAXIMAL_MIN_NONCE_INCREMENT = 2 ** 32
. However, this check does not stop from calling increaseMinNonce(MAXIMAL_MIN_NONCE_INCREMENT)
multiple of times, until it will actually overflow. It's recommended to add a check for that overflow.
address(0)
when updating address state variablesThis instance was not detected during bot-race File: ethereum/contracts/governance/Governance.sol
function updateSecurityCouncil(address _newSecurityCouncil) external onlySelf { emit ChangeSecurityCouncil(securityCouncil, _newSecurityCouncil); securityCouncil = _newSecurityCouncil; }
There's no verification for address(0)
, thus securityCouncil
can be set to address(0)
.
address verifyingContract
to EIP721_DOMAIN_TYPEHASH
in TransactionHelper.sol
According to EIP-712 documentation:
https://eips.ethereum.org/EIPS/eip-712#definition-of-domainseparator address verifyingContract the address of the contract that will verify the signature. The user-agent may do contract specific phishing prevention.
Having verifyingContract
in EIP721_DOMAIN_TYPEHASH - is considered as a protection from phishing attacks.
File: system-contracts/contracts/libraries/TransactionHelper.sol
bytes32 constant EIP712_DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId)");
can be changed to:
bytes32 constant EIP712_DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
Changing EIP712_DOMAIN_TYPEHASH
means that additional modification of code-base has to be done.
In most cases, verifyingContract
is just address(this)
. This means, that below code:
File: system-contracts/contracts/libraries/TransactionHelper.so
bytes32 domainSeparator = keccak256( abi.encode(EIP712_DOMAIN_TYPEHASH, keccak256("zkSync"), keccak256("2"), block.chainid) );
needs to be changed to:
bytes32 domainSeparator = keccak256( abi.encode(EIP712_DOMAIN_TYPEHASH, keccak256("zkSync"), keccak256("2"), block.chainid, address(this)) );
TransactionHelper.sol
According to EIP-712, the encoded EIP-712 hash of the struct must include every member field of that struct, however, in the current implementation, transaction.signature
is missing:
File: system-contracts/contracts/libraries/TransactionHelper.sol
bytes32 structHash = keccak256( abi.encode( EIP712_TRANSACTION_TYPE_HASH, _transaction.txType, _transaction.from, _transaction.to, _transaction.gasLimit, _transaction.gasPerPubdataByteLimit, _transaction.maxFeePerGas, _transaction.maxPriorityFeePerGas, _transaction.paymaster, _transaction.nonce, _transaction.value, EfficientCall.keccak(_transaction.data), keccak256(abi.encodePacked(_transaction.factoryDeps)), EfficientCall.keccak(_transaction.paymasterInput) ) );
Governance.sol
do not check if new values are the same as current stateFile: ethereum/contracts/governance/Governance.sol
function updateDelay(uint256 _newDelay) external onlySelf { emit ChangeMinDelay(minDelay, _newDelay); minDelay = _newDelay; } /// @dev Updates the address of the security council. /// @param _newSecurityCouncil The address of the new security council. function updateSecurityCouncil(address _newSecurityCouncil) external onlySelf { emit ChangeSecurityCouncil(securityCouncil, _newSecurityCouncil); securityCouncil = _newSecurityCouncil; }
updateDelay()
and updateSecurityCouncil()
does not check if input parameter is the same as the current state of the variable which is being updated. If function is being called with the same value as the current state - even though there's no change - function will success and emit an event that the change has been made.
Check _newDelay
and _newSecurityCouncil
is not equal to current state (minDelay
and securityCouncil
).
blockGasLimit
in SystemContext.sol
EVM defines gasLimit
as uint64:
https://github.com/ethereum/go-ethereum/blob/c1d5a012ea4b824e902db14e47bf147d727c2657/core/types/tx_legacy.go#L30 Gas uint64 // gas limit
However, the current implementation of zkEVM implements blockGasLimit
as uint256:
File: system-contracts/contracts/SystemContext.sol#L37
uint256 public blockGasLimit = type(uint32).max;
There shoudn't be any inconsistency between variable datatypes. The variable type should be the same as the EVM, so uint64
.
L1ERC20Bridge
won't work with rebasing tokensThere are some ERC-20 tokens which make arbitrary balance modifications outside of transfer. See this link for further reference.
The L1ERC20Bridge
saves the amount which user deposited in depositAmount
mapping. It is done, in case deposit failed on L2:
File: ethereum/contracts/bridge/L1ERC20Bridge.sol
// Save the deposited amount to claim funds on L1 if the deposit failed on L2 depositAmount[msg.sender][_l1Token][l2TxHash] = amount;
When deposit fails, user can call claimFailedDeposit()
function, to withdraw funds from the initiated deposit, that failed when finalizing on L2.
However, when token performs a rebase operation (token lowering its amount) before user calls claimFailedDeposit()
- the user won't be able to claim that tokens.
File: ethereum/contracts/bridge/L1ERC20Bridge.sol
uint256 amount = depositAmount[_depositSender][_l1Token][_l2TxHash]; require(amount > 0, "y1"); // Change the total deposited amount by the user _verifyDepositLimit(_l1Token, _depositSender, amount, true); delete depositAmount[_depositSender][_l1Token][_l2TxHash]; // Withdraw funds IERC20(_l1Token).safeTransfer(_depositSender, amount);
To fix this issue, the most reliable way would be to add amount
parameter to claimFailedDeposit()
function, and let the user decide how many tokens he wants to withdraw. He should be able to withdraw amount which is the same or lower (in case of rebase) as the cached deposited amount in depositAmount
.
getZkSyncMeta()
from SystemContractHelper.sol
does not include some fields from ZkSyncMeta
The ZkSyncMeta
struct is defined as below:
File: system-contracts/contracts/libraries/SystemContractHelper.sol
struct ZkSyncMeta { uint32 gasPerPubdataByte; uint32 heapSize; uint32 auxHeapSize; uint8 shardId; uint8 callerShardId; uint8 codeShardId; }
Function getZkSyncMeta()
from SystemContractHelper.sol
is defined as below:
File: system-contracts/contracts/libraries/SystemContractHelper.sol
function getZkSyncMeta() internal view returns (ZkSyncMeta memory meta) { uint256 metaPacked = getZkSyncMetaBytes(); meta.gasPerPubdataByte = getGasPerPubdataByteFromMeta(metaPacked); meta.shardId = getShardIdFromMeta(metaPacked); meta.callerShardId = getCallerShardIdFromMeta(metaPacked); meta.codeShardId = getCodeShardIdFromMeta(metaPacked); }
As it's demonstrated above, it does not include auxHeapSize
and heapSize
fields, which are defined in ZkSyncMeta
.
To fix this issue, add additional line to this function:
meta.heapSize = getHeapSizeFromMeta(metaPacked); meta.auxHeapSize = getAuxHeapSizeFromMeta(metaPacked);
call
in inline assemblerFile: system-contracts/contracts/libraries/EfficientCall.sol
assembly { // Clearing values before usage in assembly, since Solidity // doesn't do it by default _whoToMimic := and(_whoToMimic, cleanupMask)
Above code demonstrates how local variables should be cleaned before usage in inline assembly.
However, there are multiple of lines in EfficientCall.sol
where it's not done. All instances are related to _address
variable.
EfficientCall.sol 135: assembly { 136: success := call(_address, callAddr, 0, 0, 0xFFFF, 0, 0) 148: assembly { 149: success := call(msgValueSimulator, callAddr, _value, _address, 0xFFFF, forwardMask, 0) 163: assembly { 164: success := staticcall(_address, callAddr, 0, 0xFFFF, 0, 0) 177: assembly { 178: success := delegatecall(_address, callAddr, 0, 0xFFFF, 0, 0) 208: success := call(_address, callAddr, 0, 0, _whoToMimic, 0, 0)
NonceHolder.sol
File: system-contracts/contracts/NonceHolder.sol
function increaseMinNonce(uint256 _value) public onlySystemCall returns (uint256 oldMinNonce) { require(_value <= MAXIMAL_MIN_NONCE_INCREMENT, "The value for incrementing the nonce is too high");
increaseMinNonce()
does not verify if _value > 0
. This is important check, otherwise, it would be possible to call increaseMinNonce(0)
which actually won't increase the nonce.
processPaymasterInput()
may revert for some ERC-20 tokensThere are several popular tokens (e.g. COMP, UNI). which revert when the value passed to approve()
is greater than type(uint96).max
.
The contract won't be able to work with them. This issue affects the library. In my opinion, library functions should be implemented in a way which takes care of every edge-case. The number of supported tokens shoudn't be limited by the implementation of library function.
File: system-contracts/contracts/libraries/TransactionHelper.sol
(address token, uint256 minAllowance) = abi.decode(_transaction.paymasterInput[4:68], (address, uint256)); address paymaster = address(uint160(_transaction.paymaster)); uint256 currentAllowance = IERC20(token).allowance(address(this), paymaster); if (currentAllowance < minAllowance) { // Some tokens, e.g. USDT require that the allowance is firsty set to zero // and only then updated to the new value. IERC20(token).safeApprove(paymaster, 0); IERC20(token).safeApprove(paymaster, minAllowance); }
(address token, uint256 minAllowance) = abi.decode(_transaction.paymasterInput[4:68], (address, uint256));
- this line suggests, that minAllowance
is abi.decoded
as uint256
, thus its value can be greater than type(uint96).max
.
If that's the case (minAllowance
> type(uint96).max
) and if token
is a token which reverts when the value passed to approve()
is greater than type(uint96).max
, then:
IERC20(token).safeApprove(paymaster, minAllowance);
- this line will revert
L2ERC20Bridge
means that finalizeDeposit()
will mint tokens to address(0)There's no check if _l2Receiver
is address(0). This implies, that tokens will be minted to address(0)
.
File: contracts/zksync/contracts/bridge/L2ERC20Bridge.so
IL2StandardToken(expectedL2Token).bridgeMint(_l2Receiver, _amount);
storedBatchHash()
might return batch which was reverted by Executor
's revertBatches()
Executor
provides the ability to revertBatches()
:
File: code/contracts/ethereum/contracts/zksync/facets/Executor.sol
function revertBatches(uint256 _newLastBatch) external nonReentrant onlyValidator { require(s.totalBatchesCommitted > _newLastBatch, "v1"); // The last committed batch is less than new last batch require(_newLastBatch >= s.totalBatchesExecuted, "v2"); // Already executed batches cannot be reverted if (_newLastBatch < s.totalBatchesVerified) { s.totalBatchesVerified = _newLastBatch; } s.totalBatchesCommitted = _newLastBatch; // Reset the batch number of the executed system contracts upgrade transaction if the batch // where the system contracts upgrade was committed is among the reverted batches. if (s.l2SystemContractsUpgradeBatchNumber > _newLastBatch) { delete s.l2SystemContractsUpgradeBatchNumber; }
However, function storedBatchHash()
does not take into consideration that batch might had been reverted:
File: contracts/ethereum/contracts/zksync/facets/Getters.sol
function storedBatchHash(uint256 _batchNumber) external view returns (bytes32) { return s.storedBatchHashes[_batchNumber]; }
This behavior may lead to confusion, since it gives the user (who called storedBatchHash()
on reverted batch), the impression that that batch is still valid.
There should be an additional condition in storedBatchHash()
, which verifies:
if (_batchNumber > s.totalBatchesCommitted )
before returning the batch number.
DiamondProxy
does not check if contract existsFile: ethereum/contracts/zksync/DiamondProxy.sol
require(msg.data.length >= 4 || msg.data.length == 0, "Ut"); // Get facet from function selector Diamond.SelectorToFacet memory facet = diamondStorage.selectorToFacet[msg.sig]; address facetAddress = facet.facetAddress; require(facetAddress != address(0), "F"); // Proxy has no facet for this selector require(!diamondStorage.isFrozen || !facet.isFreezable, "q1"); // Facet is frozen
Whenever proxy delegates to a address which is not contract, the fallback will return success, which will lead to a silent failure.
In the current implementation, both faces and it's selectors are added in the diamondCut
, thus having this scenario is not very realistic - thus I've degraded the severity to Low. Nonetheless, it's a good security practice to check for contract existance before delegatecall to that address.
Our recommendation is to implement a check which verifies contract's code, before calling it.
AccountCodeStorage.sol
may lead to overflowFile: system-contracts/contracts/AccountCodeStorage.so
function getCodeSize(uint256 _input) external view override returns (uint256 codeSize) { (...) address account = address(uint160(_input)); bytes32 codeHash = getRawCodeHash(account);
File: system-contracts/contracts/AccountCodeStorage.so
function getCodeHash(uint256 _input) external view override returns (bytes32) { (...) address account = address(uint160(_input));
Both functions will return incorrect values for inputs greater than the max value of uint160
. E.g. type(uint160).max + X
will return X
. It's mandatory to enforce that _input
is not greater than type(uint160).max
.
_burnMsgValue()
in system-contracts/contracts/L2EthToken.sol
File: system-contracts/contracts/L2EthToken.sol
/// NOTE: Since this contract holds the mapping of all ether balances of the system, /// the sent `msg.value` is added to the `this` balance before the call. /// So the balance of `address(this)` is always bigger or equal to the `msg.value`! function _burnMsgValue() internal returns (uint256 amount) { amount = msg.value; // Silent burning of the ether unchecked { // This is safe, since this contract holds the ether balances, and if user // send a `msg.value` it will be added to the contract (`this`) balance. balance[address(this)] -= amount; totalSupply -= amount; } }
Even though: "the sent msg.value
is added to the this
balance before the call", the totalSupply
is not affected by amount
. This means that above assumption is not true, because totalSupply
may be smaller than amount
, which will lead to revert.
SystemContext
There's missing 0-value check when assigning value to a state variable. This implies that it's possible to set gas price to 0. Our recommendation is to implement additional require
which won't let setting _gasPrice
to 0: require(_gasPrice !=0, "gas price should be > 0")
.
File: system-contracts/contracts/SystemContext.sol
function setGasPrice(uint256 _gasPrice) external onlyCallFromBootloader { gasPrice = _gasPrice; }
MsgValueSimulator
ignores MAX_MSG_VALUE
The value
is defined as uint256
:
File: system-contracts/contracts/MsgValueSimulator.sol
fallback(bytes calldata _data) external onlySystemCall returns (bytes memory) { (uint256 value, bool isSystemCall, address to) = _getAbiParams();
while the MAX_MSG_VALUE
- as:
File: system-contracts/contracts/Constants.sol
uint256 constant MAX_MSG_VALUE = 2 ** 128 - 1;
This suggests that scenario where value > MAX_MSG_VALUE
may occur (although, it's very unlikely, thus severity has been degraded to QA. The total supply of ETH is not that big).
However, the implementation of fallback
does not take into consideration that scenario. Our recommendation is to add additonal check in MsgValueSimulator.sol
- it should revert when value
is greater than MAX_MSG_VALUE
.
The only mechanisms which enforces that value
is less than 2**128-1
is line 55: SystemContractHelper.setValueForNextFarCall(Utils.safeCastToU128(value));
, however, if protocol will be redeployed with any different value for MAX_MSG_VALUE
- this line of code won't be sufficient. It's recommended to explicitly enforce value <= MAX_MSG_VALUE
.
ContractDeployer.sol
- ensure that new value is different than the current oneFile: system-contracts/contracts/ContractDeployer.sol
function updateAccountVersion(AccountAbstractionVersion _version) external onlySystemCall { accountInfo[msg.sender].supportedAAVersion = _version; emit AccountVersionUpdated(msg.sender, _version); }
There's no verification that _version
is different than accountInfo[msg.sender].supportedAAVersion
. Whenever there's a updateAccountVersion()
call with _version
which is already set, the event AccountVersionUpdated()
will be emitted. This may be misleading (especially for critical operations, such as updating account version) - because, the version won't be changed (it still will be the same). Whenever calling updateAccountVersion()
, make sure it updates accountInfo[msg.sender].supportedAAVersion
with new version.
updateNonceOrdering
to better reflect its usageFiie: system-contracts/contracts/ContractDeployer.sol
/// @notice Updates the nonce ordering of the account. Currently, /// it only allows changes from sequential to arbitrary ordering. /// @param _nonceOrdering The new nonce ordering to use. function updateNonceOrdering(AccountNonceOrdering _nonceOrdering) external onlySystemCall {
According to NatSpec and function implementation - it's possible to change the ordering from sequential to arbitrary only. This implies, that when ordering will be changed - there's no way to change it back. This behavior implies two suggestions about code refactoring:
_nonceOrdering
parameter, thus there's only one possible value for is - AccountNonceOrdering.Arbitrary
updateNonceOrderingToArbitrary()
NonceHolder.sol
- function getRawNonce()
can be used inside more functionsFunction getRawNonce()
is defined as below:
File: system-contracts/contracts/NonceHolder.sol
function getRawNonce(address _address) public view returns (uint256) { uint256 addressAsKey = uint256(uint160(_address)); return rawNonces[addressAsKey]; }
However, multiple of functions do not use getRawNonce()
, e.g.:
function increaseMinNonce(uint256 _value) public onlySystemCall returns (uint256 oldMinNonce) { require(_value <= MAXIMAL_MIN_NONCE_INCREMENT, "The value for incrementing the nonce is too high"); uint256 addressAsKey = uint256(uint160(msg.sender)); uint256 oldRawNonce = rawNonces[addressAsKey];
can be changed to:
function increaseMinNonce(uint256 _value) public onlySystemCall returns (uint256 oldMinNonce) { require(_value <= MAXIMAL_MIN_NONCE_INCREMENT, "The value for incrementing the nonce is too high"); uint256 oldRawNonce = getRawNonce(msg.sender);
Similarly:
function incrementMinNonceIfEquals(uint256 _expectedNonce) external onlySystemCall { uint256 addressAsKey = uint256(uint160(msg.sender)); uint256 oldRawNonce = rawNonces[addressAsKey];
can be changed to:
function incrementMinNonceIfEquals(uint256 _expectedNonce) external onlySystemCall { uint256 oldRawNonce = getRawNonce(msg.sender);
File: ethereum/contracts/zksync/DiamondInit.sol
// While this does not provide a protection in the production, it is needed for local testing // Length of the L2Log encoding should not be equal to the length of other L2Logs' tree nodes preimages assert(L2_TO_L1_LOG_SERIALIZE_SIZE != 2 * 32);
This code is used only for local testing - as the comment section states. Moreover, L2_TO_L1_LOG_SERIALIZE_SIZE
is a constant variable - known before compiling time, thus checking its value is not needed at all.
File ethereum/contracts/zksync/Config.sol defines that: uint256 constant L2_TO_L1_LOG_SERIALIZE_SIZE = 88;
which implies that this assert
is redundant.
ContractDeployer.sol
events do not emit parameters from old valuesWhenever you change a state of some critical parameter, it's good practice to emit the event with old and new value. In ContractDeployer.sol
, only the new values are being emitted.
This issue occurs in updateAccountVersion()
and updateNonceOrdering()
:
File: system-contracts/contracts/ContractDeployer.sol
emit AccountVersionUpdated(msg.sender, _version); (...) emit AccountNonceOrderingUpdated(msg.sender, _nonceOrdering);
_setAllowList()
in ethereum/contracts/upgrades/BaseZkSyncUpgrade.sol
File: ethereum/contracts/upgrades/BaseZkSyncUpgrade.sol
/// @notice Change the address of the allow list smart contract /// @param _newAllowList Allow list smart contract address function _setAllowList(IAllowList _newAllowList) internal { if (_newAllowList == IAllowList(address(0))) { return; }
NatSpec misses the case, that _setAllowList
returns nothing, when _newAllowList == IAllowList(address(0))
. The behavior of this edge-case should be described in the NatSpec. Whenever there's a call to _setAllowList()
wit address(0)
, function will not update Allow List - it will return silently.
DefaultAccount.sol
File: system-contracts/contracts/DefaultAccount.so
address recoveredAddress = ecrecover(_hash, v, r, s); return recoveredAddress == address(this) && recoveredAddress != address(0);
In function _isValidSignature()
, when recoveredAddress == address(this)
is true, then we are sure that recoveredAddress != address(0)
is also true. This implies, that there's no need to perform the second check and code can be rewritten to: return recoveredAddress == address(this)
.
incrementDeploymentNonce()
from NonceHolder.sol
doubles access checks:File: system-contracts/contracts/NonceHolder.sol
function incrementDeploymentNonce(address _address) external onlySystemCall returns (uint256 prevDeploymentNonce) { require(msg.sender == address(DEPLOYER_SYSTEM_CONTRACT), "");
There is no need to use onlySystemCall
modifier, since function already enforces that msg.sender == address(DEPLOYER_SYSTEM_CONTRACT)
.
NonceHolder.sol
- the order of getters and setters are mixedIt's a good coding practice to separate functions which set value, from functions which get values. In the NonceHolder.sol
, there's lack of that separation:
function getMinNonce(address _address) public view returns (uint256) { function getRawNonce(address _address) public view returns (uint256) { function increaseMinNonce(uint256 _value) public onlySystemCall returns (uint256 oldMinNonce) { function setValueUnderNonce(uint256 _key, uint256 _value) public onlySystemCall { function getValueUnderNonce(uint256 _key) public view returns (uint256) { function incrementMinNonceIfEquals(uint256 _expectedNonce) external onlySystemCall { function getDeploymentNonce(address _address) external view returns (uint256 deploymentNonce) { function incrementDeploymentNonce(address _address) external onlySystemCall returns (uint256 prevDeploymentNonce) { function isNonceUsed(address _address, uint256 _nonce) public view returns (bool) { function validateNonceUsage(address _address, uint256 _key, bool _shouldBeUsed) external view { function _splitRawNonce(uint256 _rawMinNonce) internal pure returns (uint256 deploymentNonce, uint256 minNonce) {
Reorganize the code, so that setters are being first, and below them - getters.
SystemContext
about baseFee
In bootloader.yul
, we can check, that baseFee
is being calculated dynamically:
File: system-contracts/bootloader/bootloader.yul
baseFee := max( fairL2GasPrice, ceilDiv(pubdataBytePriceETH, MAX_L2_GAS_PER_PUBDATA()) )
However, the comment in SystemContext.sol
states, it's a constant:
File: system-contracts/contracts/SystemContext.sol
/// @dev It is currently a constant. uint256 public baseFee;
Fix this comment, since baseFee
should not be treated as constant.
_markBytecodeAsPublished()
may be misleadingFile: system-contracts/contracts/KnownCodesStorage.sol
function _markBytecodeAsPublished(bytes32 _bytecodeHash, bool _shouldSendToL1) internal { if (getMarker(_bytecodeHash) == 0) {
The name of the function and its NatSpec suggests that it is used to mark a single bytecode hash as known. The current implementation shows, that this marking occurs only when (getMarker(_bytecodeHash) == 0)
. Otherwise, function does nothing. Based on that behavior, it's hard to distinguish if function marked a single bytecode hash as known, or not. The only way to check that, would be observing if that function emitted MarkedAsKnown
event. However, there should be some additional (other than observing off-chain events) to distinguish if bytecode hash was marked or not.
Our recommendation is to re-write a function in a way it will be returning bool result - true
, when it marked a single bytecode hash as known; false
- otherwise.
TransactionHelper.sol
File: system-contracts/contracts/libraries/TransactionHelper.sol
/// @dev The type id of legacy transactions. uint8 constant EIP_2930_TX_TYPE = 0x01;
Comment should be changed to: /// @dev The type id of EIP2930 transactions.
_isValidSignature()
in DefaultAccount.sol
File: system-contracts/contracts/DefaultAccount.sol
/// @return EIP1271_SUCCESS_RETURN_VALUE if the signaure is correct. It reverts otherwise
Function can also return false
. This case is not described in the NatSpec.
_isValidSignature()
looks like this: return recoveredAddress == address(this) && recoveredAddress != address(0)
, which means, that above code in NatSpec should be changed to:
/// @return true for valid signature, false or reverts otherwise.
File: system-contracts/contracts/NonceHolder.sol
function getDeploymentNonce(address _address) external view returns (uint256 deploymentNonce) { (...) return deploymentNonce; }
File: system-contracts/contracts/ContractDeployer.so
/// @notice Returns information about a certain account. function getAccountInfo(address _address) external view returns (AccountInfo memory info) { return accountInfo[_address];
Across the whole contract, the hex value of 0 is represented as 0x00
. The only exception has been spotted in two instances:
File: system-contracts/contracts/ContractDeployer.sol
require(_bytecodeHash != bytes32(0x0), "BytecodeHash cannot be zero"); (...) ACCOUNT_CODE_STORAGE_SYSTEM_CONTRACT.getCodeHash(uint256(uint160(_newAddress))) == 0x0,
File: system-contracts/contracts/libraries/TransactionHelper.sol
uint8 constant LEGACY_TX_TYPE = 0x0;
Stick to one way of representing hex numbers. In this case, 0x0
should be 0x00
SystemContext.sol
File: system-contracts/contracts/SystemContext.sol
(uint128 previousBatchNumber, uint128 previousBatchTimestamp) = getBatchNumberAndTimestamp(); require(_newTimestamp > previousBatchTimestamp, "Timestamps should be incremental");
Function getBatchNumberAndTimestamp()
returns current batch number and current batch timestamp, thus naming those variables as previousBatchNumber
and previousBatchTimestamp
may be misleading.
Even though, we're updating those variables with _newTimestamp
, before the update, they are still current values, thus we should stick to proper naming convention.
NonceHolder.sol
does not have visibility setFile: code/system-contracts/contracts/NonceHolder.sol
uint256 constant DEPLOY_NONCE_MULTIPLIER = 2 ** 128; /// The minNonce can be increased by at 2^32 at a time to prevent it from /// overflowing beyond 2**128. uint256 constant MAXIMAL_MIN_NONCE_INCREMENT = 2 ** 32;
It's bad coding practice to avoid explicitly defining visibility.
File: system-contracts/contracts/libraries/Utils.sol
require(bytecodeLenInWords < 2 ** 16, "pp"); // bytecode length must be less than 2^16 words
It will be more readable to define constant, instead of using 2 ** 16
value. The name of the constant will self-explain why bytecodeLenInWords must be less than 2**16
if/else
statements more readableSimple if/else
construction can be shorten by using ternary operators
File: system-contracts/contracts/DefaultAccount.sol
if (_isValidSignature(txHash, _transaction.signature)) { magic = ACCOUNT_VALIDATION_SUCCESS_MAGIC; } else { magic = bytes4(0); }
can be changed to:
magic = _isValidSignature(txHash, _transaction.signature) ? ACCOUNT_VALIDATION_SUCCESS_MAGIC : bytes4(0)
ForceDeployment
struct can be moved from ConstractDeployer.sol
File: system-contracts/contracts/ContractDeployer.sol
struct ForceDeployment {
It's bad coding and design practice to have structs mixed with functions. Consider moving struct FordeDeployment
to system-contracts/contracts/interfaces/IContractDeployer.sol
File: system-contracts/contracts/openzeppelin/utils/Address.sol
function sendValue(address payable recipient, uint256 amount) internal { require( address(this).balance >= amount, "Address: insufficient balance" ); (...) function functionCallWithValue( address target, bytes memory data, uint256 value, string memory errorMessage ) internal returns (bytes memory) { require( address(this).balance >= value, "Address: insufficient balance for call" );
In the above code section, there are two functions: sendValue()
, functionCallWithValue()
, which performs basically the same check. This check can be rewritten as a modifier.
For the reusability purpose - the repeated validation statements can be reimplemented as function modifiers. This will also improve the clarity of the code - since the same block of codes won't be used in multiple of functions.
ReentrancyGuard.sol
contains links to non-existing webpagesFile: ethereum/contracts/common/ReentrancyGuard.sol
20: * https://blog.openzeppelin.com/reentrancy-after-istanbul/[Reentrancy After Istanbul].
Make sure that links in the comment section redirects to valid, existing websites.
/// @notice A function that performs force deploy
"force deploy" is not grammatically correct, it should be changed to "force deployment"
/// - It does not have a silent fallback method and will revert if it's called for a method it hasn't implemented.
"hasn't implemented", should be changed to "hasn't been implementing".
/// @dev It will used for the L1 batch -> L2 block migration in Q3 2023 only.
"will used", should be changed to "will be used".
// If the length is not equal to one, then only using the length can it be encoded definitely.
"length can it be encoded definitely" should be changed to "length, it can be encoded definitely"
// If the length is not equal to one, then only using the length can it be encoded definitely.
"length can it be encoded definitely" should be changed to "length, it can be encoded definitely"
// If the length is not equal to one, then only using the length can it be encoded definitely.
"length can it be encoded definitely" should be changed to "length, it can be encoded definitely"
/// - Store the latest 257 blocks's hashes.
"blocks's" should be changed to "block's".
File: system-contracts/contracts/SystemContext.sol
// Due to virtual blocks upgrade, we'll have to use the following logic for retreiving the blockhash:
"retreiving" should be changed to "retriving".
// The fact there is are enough balance for the account
"there is are" should be changed to "there is"
* The users can either marked a range of nonces by increasing the `minNonce`. This way all the nonces
"can either marked" should be changed to "can either mark".
require
are not descriptiveMake sure to use more descriptive messages in require
statements. It will be helpful in debuging reverts. Moreover, it will increase a user experience - if his/her transaction reverts, he/she will see more descriptive reason of that revert.
This issue occurs in multiple of instances:
./contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol: require(_l2TokenBeacon != address(0), "nf"); ./contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol: require(_governor != address(0), "nh"); ./contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol: require(_factoryDeps.length == 3, "mk"); ./contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol: require(_amount != 0, "2T"); ./contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol: require(amount == _amount, "1T"); ./contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol: require(proofValid, "yn"); ./contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol: require(amount > 0, "y1"); ./contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol: require(!isWithdrawalFinalized[_l2BatchNumber][_l2MessageIndex], "pw"); ./contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol: require(success, "nq"); ./contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol: require(_l2ToL1message.length == 76, "kk"); ./contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol: require(bytes4(functionSignature) == this.finalizeWithdrawal.selector, "nt"); ./contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol: require(totalDepositedAmountPerUser[_l1Token][_depositor] + _amount <= limitData.depositCap, "d1"); ./contracts/ethereum/contracts/bridge/L1WethBridge.sol: require(success, "vq"); ./contracts/ethereum/contracts/bridge/L1WethBridge.sol: require(msg.sender == l1WethAddress || msg.sender == address(zkSync), "pn"); ./contracts/ethereum/contracts/common/AllowList.sol: require(targetsLength == _accessModes.length, "yg"); ./contracts/ethereum/contracts/common/AllowList.sol: require(callersLength == _targets.length, "yw"); ./contracts/ethereum/contracts/common/AllowList.sol: require(callersLength == _functionSigs.length, "yx"); ./contracts/ethereum/contracts/common/AllowList.sol: require(callersLength == _enables.length, "yy"); ./contracts/ethereum/contracts/common/AllowListed.sol: require(_allowList.canCall(msg.sender, address(this), msg.sig), "nr"); ./contracts/ethereum/contracts/common/libraries/L2ContractHelper.sol: require(_bytecode.length % 32 == 0, "pq"); ./contracts/ethereum/contracts/common/libraries/L2ContractHelper.sol: require(bytecodeLenInWords < 2**16, "pp"); ./contracts/ethereum/contracts/common/libraries/L2ContractHelper.sol: require(bytecodeLenInWords % 2 == 1, "ps"); ./contracts/ethereum/contracts/common/libraries/L2ContractHelper.sol: require(version == 1 && _bytecodeHash[1] == bytes1(0), "zf"); ./contracts/ethereum/contracts/common/libraries/L2ContractHelper.sol: require(_bytecodeLen(_bytecodeHash) % 2 == 1, "uy"); ./contracts/ethereum/contracts/common/ReentrancyGuard.sol: require(lockSlotOldValue == 0, "1B"); ./contracts/ethereum/contracts/common/ReentrancyGuard.sol: require(_status == _NOT_ENTERED, "r1"); ./contracts/ethereum/contracts/zksync/DiamondInit.sol: require(address(_initalizeData.verifier) != address(0), "vt"); ./contracts/ethereum/contracts/zksync/DiamondInit.sol: require(_initalizeData.governor != address(0), "vy"); ./contracts/ethereum/contracts/zksync/DiamondInit.sol: require(_initalizeData.admin != address(0), "hc"); ./contracts/ethereum/contracts/zksync/DiamondInit.sol: require(_initalizeData.priorityTxMaxGasLimit <= L2_TX_MAX_GAS_LIMIT, "vu"); ./contracts/ethereum/contracts/zksync/DiamondProxy.sol: require(_chainId == block.chainid, "pr"); ./contracts/ethereum/contracts/zksync/DiamondProxy.sol: require(msg.data.length >= 4 || msg.data.length == 0, "Ut"); ./contracts/ethereum/contracts/zksync/DiamondProxy.sol: require(!diamondStorage.isFrozen || !facet.isFreezable, "q1"); ./contracts/ethereum/contracts/zksync/facets/Admin.sol: require(msg.sender == pendingGovernor, "n4"); ./contracts/ethereum/contracts/zksync/facets/Admin.sol: require(msg.sender == pendingAdmin, "n4"); ./contracts/ethereum/contracts/zksync/facets/Admin.sol: require(_newPriorityTxMaxGasLimit <= L2_TX_MAX_GAS_LIMIT, "n5"); ./contracts/ethereum/contracts/zksync/facets/Admin.sol: require(!diamondStorage.isFrozen, "a9"); ./contracts/ethereum/contracts/zksync/facets/Admin.sol: require(diamondStorage.isFrozen, "a7"); ./contracts/ethereum/contracts/zksync/facets/Base.sol: require(msg.sender == s.governor, "1g"); ./contracts/ethereum/contracts/zksync/facets/Base.sol: require(s.validators[msg.sender], "1h"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(expectedNumberOfLayer1Txs == _newBatch.numberOfLayer1Txs, "ta"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(batchTimestamp == _expectedBatchTimestamp, "tb"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(_previousBatchTimestamp < batchTimestamp, "h3"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(block.timestamp - COMMIT_TIMESTAMP_NOT_OLDER <= batchTimestamp, "h1"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(lastL2BlockTimestamp <= block.timestamp + COMMIT_TIMESTAMP_APPROXIMATION_DELTA, "h2"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(!_checkBit(processedLogs, uint8(logKey)), "kp"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(logSender == L2_TO_L1_MESSENGER_SYSTEM_CONTRACT_ADDR, "lm"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(logSender == L2_TO_L1_MESSENGER_SYSTEM_CONTRACT_ADDR, "ln"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(providedL2ToL1PubdataHash == logValue, "wp"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(logSender == L2_TO_L1_MESSENGER_SYSTEM_CONTRACT_ADDR, "lb"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(logSender == L2_SYSTEM_CONTEXT_SYSTEM_CONTRACT_ADDR, "sc"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(logSender == L2_SYSTEM_CONTEXT_SYSTEM_CONTRACT_ADDR, "sv"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(logSender == L2_BOOTLOADER_ADDRESS, "bl"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(logSender == L2_BOOTLOADER_ADDRESS, "bk"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(logSender == L2_BOOTLOADER_ADDRESS, "bu"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(_expectedSystemContractUpgradeTxHash == logValue, "ut"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(processedLogs == 127, "b7"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(processedLogs == 255, "b8"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(s.l2SystemContractsUpgradeBatchNumber == 0, "ik"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(_hashStoredBatchInfo(_prevBatch) == s.storedBatchHashes[currentTotalBatchesVerified], "t1"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(s.totalBatchesCommitted > _newLastBatch, "v1"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(_newLastBatch >= s.totalBatchesExecuted, "v2"); ./contracts/ethereum/contracts/zksync/facets/Executor.sol: require(_batch.systemLogs.length <= MAX_L2_TO_L1_LOGS_COMMITMENT_BYTES, "pu"); ./contracts/ethereum/contracts/zksync/facets/Getters.sol: require(ds.selectorToFacet[_selector].facetAddress != address(0), "g2"); ./contracts/ethereum/contracts/zksync/facets/Mailbox.sol: require(callSuccess, "pz"); ./contracts/ethereum/contracts/zksync/facets/Mailbox.sol: require(_batchNumber <= s.totalBatchesExecuted, "xx"); ./contracts/ethereum/contracts/zksync/facets/Mailbox.sol: require(hashedLog != L2_L1_LOGS_TREE_DEFAULT_LEAF_HASH, "tw"); ./contracts/ethereum/contracts/zksync/facets/Mailbox.sol: require(!s.isEthWithdrawalFinalized[_l2BatchNumber][_l2MessageIndex], "jj"); ./contracts/ethereum/contracts/zksync/facets/Mailbox.sol: require(proofValid, "pi"); ./contracts/ethereum/contracts/zksync/facets/Mailbox.sol: require(_l2GasPerPubdataByteLimit == REQUIRED_L2_GAS_PRICE_PER_PUBDATA, "qp"); ./contracts/ethereum/contracts/zksync/facets/Mailbox.sol: require(s.totalDepositedAmountPerUser[_depositor] + _amount <= limitData.depositCap, "d2"); ./contracts/ethereum/contracts/zksync/facets/Mailbox.sol: require(_factoryDeps.length <= MAX_NEW_FACTORY_DEPS, "uj"); ./contracts/ethereum/contracts/zksync/facets/Mailbox.sol: require(msg.value >= baseCost + _l2Value, "mv"); ./contracts/ethereum/contracts/zksync/facets/Mailbox.sol: require(_message.length >= 56, "pm"); ./contracts/ethereum/contracts/zksync/facets/Mailbox.sol: require(bytes4(functionSignature) == this.finalizeEthWithdrawal.selector, "is"); ./contracts/ethereum/contracts/zksync/libraries/Diamond.sol: require(_facet == address(0), "a1"); ./contracts/ethereum/contracts/zksync/libraries/Diamond.sol: require(oldFacet.facetAddress != address(0), "a2"); ./contracts/ethereum/contracts/zksync/libraries/Diamond.sol: require(_isSelectorFreezable == ds.selectorToFacet[selector0].isFreezable, "J1"); ./contracts/ethereum/contracts/zksync/libraries/Diamond.sol: require(data.length == 32, "lp"); ./contracts/ethereum/contracts/zksync/libraries/Merkle.sol: require(pathLength > 0, "xc"); ./contracts/ethereum/contracts/zksync/libraries/Merkle.sol: require(pathLength < 256, "bt"); ./contracts/ethereum/contracts/zksync/libraries/Merkle.sol: require(_index < (1 << pathLength), "px"); ./contracts/ethereum/contracts/zksync/libraries/TransactionValidator.sol: require(l2GasForTxBody <= _priorityTxMaxGasLimit, "ui"); ./contracts/ethereum/contracts/zksync/libraries/TransactionValidator.sol: require(l2GasForTxBody / _transaction.gasPerPubdataByteLimit <= PRIORITY_TX_MAX_PUBDATA, "uk"); ./contracts/ethereum/contracts/zksync/libraries/TransactionValidator.sol: require(_transaction.from <= type(uint16).max, "ua"); ./contracts/ethereum/contracts/zksync/libraries/TransactionValidator.sol: require(_transaction.to <= type(uint160).max, "ub"); ./contracts/ethereum/contracts/zksync/libraries/TransactionValidator.sol: require(_transaction.paymaster == 0, "uc"); ./contracts/ethereum/contracts/zksync/libraries/TransactionValidator.sol: require(_transaction.value == 0, "ud"); ./contracts/ethereum/contracts/zksync/libraries/TransactionValidator.sol: require(_transaction.reserved[0] == 0, "ue"); ./contracts/ethereum/contracts/zksync/libraries/TransactionValidator.sol: require(_transaction.reserved[1] <= type(uint160).max, "uf"); ./contracts/ethereum/contracts/zksync/libraries/TransactionValidator.sol: require(_transaction.reserved[2] == 0, "ug"); ./contracts/ethereum/contracts/zksync/libraries/TransactionValidator.sol: require(_transaction.reserved[3] == 0, "uo"); ./contracts/ethereum/contracts/zksync/libraries/TransactionValidator.sol: require(_transaction.signature.length == 0, "uh"); ./contracts/ethereum/contracts/zksync/libraries/TransactionValidator.sol: require(_transaction.paymasterInput.length == 0, "ul"); ./contracts/ethereum/contracts/zksync/libraries/TransactionValidator.sol: require(_transaction.reservedDynamic.length == 0, "um"); ./contracts/ethereum/contracts/zksync/libraries/TransactionValidator.sol: require(_totalGasLimit >= overhead, "my"); ./contracts/ethereum/contracts/zksync/ValidatorTimelock.sol: require(msg.sender == validator, "8h"); ./contracts/ethereum/contracts/zksync/ValidatorTimelock.sol: require(block.timestamp >= commitBatchTimestamp + delay, "5c"); ./contracts/zksync/contracts/bridge/L2ERC20Bridge.sol: require(_l1Bridge != address(0), "bf"); ./contracts/zksync/contracts/bridge/L2ERC20Bridge.sol: require(_l2TokenProxyBytecodeHash != bytes32(0), "df"); ./contracts/zksync/contracts/bridge/L2ERC20Bridge.sol: require(_governor != address(0), "sf"); ./contracts/zksync/contracts/bridge/L2ERC20Bridge.sol: require(AddressAliasHelper.undoL1ToL2Alias(msg.sender) == l1Bridge, "mq"); ./contracts/zksync/contracts/bridge/L2ERC20Bridge.sol: require(deployedToken == expectedL2Token, "mt"); ./contracts/zksync/contracts/bridge/L2ERC20Bridge.sol: require(currentL1Token == _l1Token, "gg"); ./contracts/zksync/contracts/bridge/L2ERC20Bridge.sol: require(l1Token != address(0), "yh"); ./contracts/zksync/contracts/bridge/L2ERC20Bridge.sol: require(success, "mk"); ./contracts/zksync/contracts/bridge/L2WethBridge.sol: require(msg.sender == l2WethAddress, "pd"); ./system-contracts/contracts/libraries/Utils.sol: require(_bytecode.length % 32 == 0, "po"); ./system-contracts/contracts/libraries/Utils.sol: require(bytecodeLenInWords < 2 ** 16, "pp"); ./system-contracts/contracts/libraries/Utils.sol: require(bytecodeLenInWords % 2 == 1, "pr");
#0 - bytes032
2023-10-30T10:11:43Z
9 l 4 r 20 nc
[QA-1] Unsafe addition in unchecked block may overflow in NonceHolder.sol l
[QA-2] Missing checks for address(0) when updating address state variables bot
[QA-3] Consider adding address verifyingContract to EIP721_DOMAIN_TYPEHASH in TransactionHelper.sol r
[QA-4] Violation of EIP-712 in TransactionHelper.sol l
[QA-5] Functions in Governance.sol do not check if new values are the same as current state bot
[QA-6] Incorrect type of blockGasLimit in SystemContext.sol low
[QA-7] L1ERC20Bridge won't work with rebasing tokens r
[QA-8] getZkSyncMeta() from SystemContractHelper.sol does not include some fields from ZkSyncMeta r
[QA-9] Lack of cleaning of variables before call in inline assembler nc
[QA-10] Lack of 0-value check in NonceHolder.sol nc
[QA-11] processPaymasterInput() may revert for some ERC-20 tokens x
[QA-12] Lack of address(0) check in L2ERC20Bridge means that finalizeDeposit() will mint tokens to address(0) l
[QA-13] storedBatchHash() might return batch which was reverted by Executor's revertBatches() r
[QA-14] DiamondProxy does not check if contract exists l
[QA-15] Lack of input validation in AccountCodeStorage.sol may lead to overflow l
[QA-16] Incorrect assumption may lead to revert in _burnMsgValue() in system-contracts/contracts/L2EthToken.sol l
[QA-17] The current gas price might be set to 0 in SystemContext l
[QA-18] MsgValueSimulator ignores MAX_MSG_VALUE l
[N-1] In ContractDeployer.sol - ensure that new value is different than the current one bot
[N-2] Update name of updateNonceOrdering to better reflect its usage nc
[N-3] In NonceHolder.sol - function getRawNonce() can be used inside more functions nc
[N-4] Unused code / code for local testing should be removed nv
[N-5] ContractDeployer.sol events do not emit parameters from old values bot
[N-6] NatSpec does not properly describes _setAllowList() in ethereum/contracts/upgrades/BaseZkSyncUpgrade.sol nc
[N-7] Redundant check in DefaultAccount.sol nc
[N-8] incrementDeploymentNonce() from NonceHolder.sol doubles access checks: nc
[N-9] In NonceHolder.sol - the order of getters and setters are mixed nc
[N-10] Incorrect comment in SystemContext about baseFee nc
[N-11] The behavior of _markBytecodeAsPublished() may be misleading nc
[N-12] Incorrect comment in TransactionHelper.sol nc
[N-13] Improper NatSpec for _isValidSignature() in DefaultAccount.sol nc
[N-14] Some functions define named returns, but still return value via return statement bot
[N-15] Inconsistency in representing hex 0x00 nc
[N-16] Misleading variable naming in SystemContext.sol nc
[N-17] Constants in NonceHolder.sol does not have visibility set nc
[N-18] Define constants, instead of using magic numbers: bot
[N-19] Use ternary operators to make if/else statements more readable nc
[N-20] ForceDeployment struct can be moved from ConstractDeployer.sol nc
[N-21] Repeated checks can be implemented as modifiers bot
[N-22] ReentrancyGuard.sol contains links to non-existing webpages nc
[N-23] Comments are not grammatically correct: nc
[N-24] Messages in require are not descriptive nc
#1 - lsaudit
2023-12-01T19:10:03Z
Hey, firstly - thank you for the access to the repo and for judging it so quickly! I've reviewed your remarks and I'd like to ask you to take another look at some of my issues.
The exactly same issue was evaluated as Low in https://github.com/code-423n4/2023-10-zksync-findings/issues/1124 (QA-02), while my issue as Refactor. I also think, that this one should be evaluated as Low, instead of Refactor, since it's the same issue.
As mentioned in my report, this issue was not included in the bot-report, thus I've reported it here.
In the bot report, I see only one "missing checks for address(0x0)" issues: L-21 (which mentions ValidatorTimelock.sol
, Admin.sol
and SystemContext.sol
- it does not mention Governance.sol
which I've reported).
The other issue from bot report is L-20 - which reports missing checks for address(0x0) in the constructor/initializer. It does indeed report Governance.sol
, however, the bot-report mentions, lack of address(0) check in the constructor. In my issue - QA-2 - I'm reporting that there's no address(0) check in updateSecurityCouncil()
- this is separate function which was not included in the bot report.
Even when the developer would fix the bot's L-20 (and checks for address(0) in the constructor) - it still will be possible to set securityCouncil_
to address(0) by calling updateSecurityCouncil()
. IMHO, this makes this issue not a duplicate, but a valid Low finding.
EIP-721 explicitly states, that verifyingContract
is used for phishing prevention. It has a real value for the security of the contract itself. I think this issue should be considered as Low, instead of Refactor, since missing verifyingContract
has an impact on overall security of EIP721_DOMAIN_TYPEHASH.
Solidity docs straightforwardly mentions that "To be safe, always clear the data properly before you use it in a context where this is important". Since in all instances, the issue affects the address
variable, and then we call, staticcall and delegatecall to that address - a potential impact might be much higher than NC. I've reported it as Low.
I don't see this entry in the bot-report. I think it was confused with bot's N‑43 - "Events that mark critical parameter changes should contain both the old and the new value".
The bot reports, that when we're updating nonce ordering, the AccountNonceOrderingUpdated()
event should emit both previous and new value of nonce ordering.
My report states, that we should verify if updateNonceOrdering()
really updates the ordering. In other words, if the current version of nonce ordering is AccountNonceOrdering.Sequential
and we're calling updateNonceOrdering(AccountNonceOrdering.Sequential)
, we're basically don't update anything. However, function will still emit an event which might be confusing (as it suggests, that the update had taken place).
The recommendation for this issue is to add additional check: require(_nonceOrdering != currentInfo.nonceOrdering, "You are not updating anything")
. As mentioned above, this is not bot's duplicate and probably should be evaluated as Refactoring issue.
Same as above, bot reports that event should contain both the old and the new value. May issue states that there should be an additional check, to make sure we're not updating the same value.
E.g. if minDelay = 123
and we call updateDelay(123)
, function will still emit an event, even thought, nothing will be changed (minDelay
will still be 123
). There should be additional check: require(minDelay != _newDelay, "You are not updating anything!")
.
I've reported this issue in two functions updateDelay()
and updateSecurityCouncil()
.
I've grepped the whole bot-report section and I do not see anything which suggests it's already set in a bot report. Could you please point me out to the bot's issue?
Moreover, would this degraded report (from Medium to QA) be also included in my QA report: https://github.com/code-423n4/2023-10-zksync-findings/issues/614
#2 - lsaudit
2023-12-03T14:22:08Z
Hey @GalloDaSballo
I've one more additional issue related to my QA report.
I've noticed, that multiple of issues were evaluated as Medium, whenever inconsistent behavior of the EVM with the zkSyncVM was demonstrated:
E.g., some of your comments:
https://github.com/code-423n4/2023-10-zksync-findings/issues/92#issuecomment-1812462720
We have an historical record of awarding non-evm equivalence that can cause damage as med, so I'm inclined to maintain the severity
https://github.com/code-423n4/2023-10-zksync-findings/issues/426#issuecomment-1826878445 https://github.com/code-423n4/2023-10-zksync-findings/issues/175#issuecomment-1826878542 https://github.com/code-423n4/2023-10-zksync-findings/issues/174#issuecomment-1826878653
(...) Because the goal of the zkSync EVM is to be the EVM compatible, Medium Severity seems appropriate
https://github.com/code-423n4/2023-10-zksync-findings/issues/133#issuecomment-1826808035
(...) I believe Medium Severity is most appropriate as the finding demonstrates an inconsistent behaviour of the EVM with the zkSyncVM I agree with the impact, but believe that such finding belongs to the Medium Severity classification
https://github.com/code-423n4/2023-10-zksync-findings/issues/25#issuecomment-1829722703
Because the behaviour differs from the EVM, I believe that Medium Severity is appropriate
Since I was not aware that proving inconsistency between zkSync and EVM would be evaluated as Medium - I wrote [QA-6] Incorrect type of blockGasLimit in SystemContext.sol
in QA Report, instead of reporting it as a separate Medium issue.
Could you plese verify, if above issue (QA-6) should also be treated as inconsistency between EVM and zkSync and be classified as Medium?
I've redesigned the structure of my finding (added Impact and Recommended Mitigation Steps, PoC remained the same and was copy-pasted from my QA Report).
The gas limit of a block defines the maximum amount of gas that all the transactions inside the block are allowed to consume.
The EVM defines it as uint64
. However, in zkSync, the gas limit is defined as uint256
. This discrepancy implies, that the zkSync gas limit can be changed to a higher value (since it's uint256
) than EVM (in EVM the max value needs to fit in uint64
).
The goal of the zkSync EVM is to be the EVM compatible. This goal is not fulfilled. zkSync implementation allows for setting higher gas limit (because gas limit is defined as uint256
) than EVM.
EVM defines gasLimit
as uint64:
https://github.com/ethereum/go-ethereum/blob/c1d5a012ea4b824e902db14e47bf147d727c2657/core/types/tx_legacy.go#L30 Gas uint64 // gas limit
However, the current implementation of zkEVM implements blockGasLimit
as uint256:
File: system-contracts/contracts/SystemContext.sol#L37
uint256 public blockGasLimit = type(uint32).max;
There shoudn't be any inconsistency between variable datatypes. The variable type should be the same as the EVM, so uint64
.
uint256 public blockGasLimit = type(uint32).max;
should be changed to uint64 public blockGasLimit = type(uint32).max;
#3 - GalloDaSballo
2023-12-07T20:02:13Z
I disagree with the escalation for 2 reasons: Both values will never overfllow The size of the value is still one word
#4 - lsaudit
2023-12-10T14:12:07Z
escalation: thank you, that explanation seems fair!
rest of my QA: I've missed the google docs link when I was escalating, but now I see that my report is already graded as A. Upgrading QA-6 was disputed, thus my QA score won't be affected. Therefore, please feel free to ignore the rest of my comments. If this QA is already graded as A, further escalation won't change a thing, so I don't wanna unnecessary waste judge's time!
#5 - c4-judge
2023-12-10T20:17:05Z
GalloDaSballo marked the issue as grade-a
🌟 Selected for report: lsaudit
Also found by: 0xAnah, 0xhacksmithh, 0xsomeone, SM3_SS, Sathish9098, albahaca, c3phas, hunter_w3b
1944.3675 USDC - $1,944.37
As stated in the scope - the Gas Report had been prepared for L1 contracts only.
L2ContractHelper.sol
File: ethereum/contracts/common/libraries/L2ContractHelper.sol
bytes32 constant CREATE2_PREFIX = keccak256("zksyncCreate2");
_bytecode
is calculated twice in L2ContractHelper.sol
Calculating length of bytes every time costs gas. The better solution would be to calculate the length once and save it in a local variable:
File: ethereum/contracts/common/libraries/L2ContractHelper.sol
require(_bytecode.length % 32 == 0, "pq"); uint256 bytecodeLenInWords = _bytecode.length / 32;
can be changed to:
uint256 _bytecodeLength = _bytecode.length; require(_bytecodeLength % 32 == 0, "pq"); uint256 bytecodeLenInWords = _bytecodeLength / 32;
require
in hashL2Bytecode
in L2ContractHelper.sol
can be moved on topFile: ethereum/contracts/common/libraries/L2ContractHelper.sol
require(_bytecode.length % 32 == 0, "pq"); uint256 bytecodeLenInWords = _bytecode.length / 32; require(bytecodeLenInWords < 2**16, "pp"); // bytecode length must be less than 2^16 words
Since _bytecode.length
is divisible by 32 (the first require
), we know, that we can divide _bytecode.length
by 32, without a remainder.
Let's take a look at the 2nd require
:
bytecodeLenInWords < 2**16 => _bytecode.length / 32 < 2**16 => _bytecode.length < 2**16 * 32 => _bytecode.length < 2**21
This implies that we can rewrite above code snippet as:
require(_bytecode.length % 32 == 0, "pq"); require(_bytecode.length < 2**21, "pp"); // bytecode length must be less than 2^16 words uint256 bytecodeLenInWords = _bytecode.length / 32;
That way, when _bytecode.length
will be too big, we will revert immediately, instead of wasting gas on executing line: uint256 bytecodeLenInWords = _bytecode.length / 32
.
That line will be executed after the 2nd require
.
Moreover, please keep in mind, to cache _bytecode.length
into local variable - as it was suggested in G-02
:
uint256 _bytecodeLength = _bytecode.length; require(_bytecodeLength % 32 == 0, "pq"); require(_bytecodeLength < 2**21, "pp"); // bytecode length must be less than 2^16 words uint256 bytecodeLenInWords = _bytecodeLength / 32;
L2ContractHelper.sol
validateBytecodeHash
Variable version
is used only once, which means, it doesn't need to be declared at all:
Below code snippet can be changed from:
File: ethereum/contracts/common/libraries/L2ContractHelper.sol
uint8 version = uint8(_bytecodeHash[0]); require(version == 1 && _bytecodeHash[1] == bytes1(0), "zf"); // Incorrectly formatted bytecodeHash
to:
require(uint8(_bytecodeHash[0]) == 1 && _bytecodeHash[1] == bytes1(0), "zf"); // Incorrectly formatted bytecodeHash
computeCreate2Address
Variables senderBytes
, data
are used only once, which means, they don't need to be declared at all.
Below code snippet can be changed from:
File: ethereum/contracts/common/libraries/L2ContractHelper.sol
bytes32 senderBytes = bytes32(uint256(uint160(_sender))); bytes32 data = keccak256( bytes.concat(CREATE2_PREFIX, senderBytes, _salt, _bytecodeHash, _constructorInputHash) ); return address(uint160(uint256(data)));
to:
return address(uint160(uint256(keccak256(bytes.concat(CREATE2_PREFIX, bytes32(uint256(uint160(_sender))), _salt, _bytecodeHash, _constructorInputHash)))));
AllowList.sol
_setPermissionToCall
Variable currentPermission
is used only once, which means it doesn't need to be declared at all.
Below code snippet can be changed from:
File: ethereum/contracts/common/AllowList.sol
bool currentPermission = hasSpecialAccessToCall[_caller][_target][_functionSig]; if (currentPermission != _enable) {
to:
if (hasSpecialAccessToCall[_caller][_target][_functionSig] != _enable) {
_factoryDeps
is calculated multiple of times in _verifyFactoryDeps
in BaseZkSyncUpgrade.sol
.Calculating length of array cost gas. Instead of doing it every time, calculate it once and assign that value to local variable.
_factoryDeps.length
is being calculated:
require(_factoryDeps.length == _expectedHashes.length, "Wrong number of factory deps");
in the 1st require
require(_factoryDeps.length <= MAX_NEW_FACTORY_DEPS, "Factory deps can be at most 32");
in the 2nd require
for (uint256 i = 0; i < _factoryDeps.length; ++i) {
in loop (this one was reported in the bot-race)File: ethereum/contracts/upgrades/BaseZkSyncUpgrade.sol
require(_factoryDeps.length == _expectedHashes.length, "Wrong number of factory deps"); require(_factoryDeps.length <= MAX_NEW_FACTORY_DEPS, "Factory deps can be at most 32"); for (uint256 i = 0; i < _factoryDeps.length; ++i) {
BridgeInitializationHelper.sol
requestDeployTransaction
Variable deployCalldata
is used only once, which means it doesn't need to be declared at all.Below code snippet can be changed from:
File: ethereum/contracts/common/AllowList.sol
bytes memory deployCalldata = abi.encodeCall( IL2ContractDeployer.create2, (bytes32(0), _bytecodeHash, _constructorData) ); _zkSync.requestL2Transaction{value: _deployTransactionFee}( L2_DEPLOYER_SYSTEM_CONTRACT_ADDR, 0, deployCalldata, DEPLOY_L2_BRIDGE_COUNTERPART_GAS_LIMIT, REQUIRED_L2_GAS_PRICE_PER_PUBDATA, _factoryDeps, msg.sender );
to:
_zkSync.requestL2Transaction{value: _deployTransactionFee}( L2_DEPLOYER_SYSTEM_CONTRACT_ADDR, 0, abi.encodeCall( IL2ContractDeployer.create2, (bytes32(0), _bytecodeHash, _constructorData) ), DEPLOY_L2_BRIDGE_COUNTERPART_GAS_LIMIT, REQUIRED_L2_GAS_PRICE_PER_PUBDATA, _factoryDeps, msg.sender );
L1WethBridge.sol
initialize
Variables l2WethBridgeImplementationBytecodeHash
and l2WethBridgeProxyBytecodeHash
are used only once, which means they don't need to be declared at all.Below code snippet can be changed from:
File: ethereum/contracts/bridge/L1WethBridge.sol
bytes32 l2WethBridgeImplementationBytecodeHash = L2ContractHelper.hashL2Bytecode(_factoryDeps[0]); bytes32 l2WethBridgeProxyBytecodeHash = L2ContractHelper.hashL2Bytecode(_factoryDeps[1]); // Deploy L2 bridge implementation contract address wethBridgeImplementationAddr = BridgeInitializationHelper.requestDeployTransaction( zkSync, _deployBridgeImplementationFee, l2WethBridgeImplementationBytecodeHash, "", // Empty constructor data _factoryDeps // All factory deps are needed for L2 bridge ); (...) // Deploy L2 bridge proxy contract l2Bridge = BridgeInitializationHelper.requestDeployTransaction( zkSync, _deployBridgeProxyFee, l2WethBridgeProxyBytecodeHash, l2WethBridgeProxyConstructorData, // No factory deps are needed for L2 bridge proxy, because it is already passed in the previous step new bytes[](0) );
to:
// Deploy L2 bridge implementation contract address wethBridgeImplementationAddr = BridgeInitializationHelper.requestDeployTransaction( zkSync, _deployBridgeImplementationFee, L2ContractHelper.hashL2Bytecode(_factoryDeps[0]);, "", // Empty constructor data _factoryDeps // All factory deps are needed for L2 bridge ); (...) // Deploy L2 bridge proxy contract l2Bridge = BridgeInitializationHelper.requestDeployTransaction( zkSync, _deployBridgeProxyFee, L2ContractHelper.hashL2Bytecode(_factoryDeps[1]), l2WethBridgeProxyConstructorData, // No factory deps are needed for L2 bridge proxy, because it is already passed in the previous step new bytes[](0) );
finalizeWithdrawal
Variables alreadyFinalised
, l2ToL1Message
and success
are used only once, which means they don't need to be declared at all.Below code snippet can be changed from:
File: ethereum/contracts/bridge/L1WethBridge.sol
bool alreadyFinalised = zkSync.isEthWithdrawalFinalized(_l2BatchNumber, _l2MessageIndex); if (alreadyFinalised) { // Check that the specified message was actually sent while withdrawing eth from L2. L2Message memory l2ToL1Message = L2Message({ txNumberInBatch: _l2TxNumberInBatch, sender: L2_ETH_TOKEN_SYSTEM_CONTRACT_ADDR, data: _message }); bool success = zkSync.proveL2MessageInclusion(_l2BatchNumber, _l2MessageIndex, l2ToL1Message, _merkleProof); require(success, "vq");
to:
if (zkSync.isEthWithdrawalFinalized(_l2BatchNumber, _l2MessageIndex)) { require(zkSync.proveL2MessageInclusion(_l2BatchNumber, _l2MessageIndex, L2Message({ txNumberInBatch: _l2TxNumberInBatch, sender: L2_ETH_TOKEN_SYSTEM_CONTRACT_ADDR, data: _message }), _merkleProof), "vq");
amount
is greater than zero before performing safeTransfer
in L1WethBridge.sol
File: ethereum/contracts/bridge/L1WethBridge.sol
IERC20(l1WethAddress).safeTransfer(l1WethWithdrawReceiver, amount);
safeTransfer
'ing when amount = 0
is a waste of gas, since there will be no funds transferred.
To save gas, check if amount
is greater than 0, before calling safeTransfer
.
offset
in the last call of UnsafeBytes.readAddress
in L1WethBridge.sol
A simple test in Remix IDE has been created, to compare gas usage of assigning more than one value from function call:
function getUints() public pure returns (uint, uint) {return (1, 2);} function getAll() public view { uint a; uint b; (a, b) = getUints(); uint g = gasleft(); (a, b) = getUints(); console.log(g - gasleft()); } function getOne() public view { uint a; uint b; (a, b) = getUints(); uint g = gasleft(); (a, ) = getUints(); console.log(g - gasleft()); }
getOne()
costs 74 gas, while getAll()
costs 82 gas. This implies, that we shouldn't assign every parameter from function call if it's not needed.
In the last call of UnsafeBytes.readAddress(_message, offset)
(line 298):
File: ethereum/contracts/bridge/L1WethBridge.sol
(l1WethReceiver, offset) = UnsafeBytes.readAddress(_message, offset);
we won't need offset
anymore, thus we can change line 298 to: (l1WethReceiver, ) = UnsafeBytes.readAddress(_message, offset)
.
L1ERC20Bridge.sol
initialize
Variables l2WethBridgeImplementationBytecodeHash
and l2WethBridgeProxyBytecodeHash
are used only once, which means they don't need to be declared at all.
Below code snippet can be changed from:
File: ethereum/contracts/bridge/L1ERC20Bridge.sol
bytes32 l2BridgeImplementationBytecodeHash = L2ContractHelper.hashL2Bytecode(_factoryDeps[0]); bytes32 l2BridgeProxyBytecodeHash = L2ContractHelper.hashL2Bytecode(_factoryDeps[1]); // Deploy L2 bridge implementation contract address bridgeImplementationAddr = BridgeInitializationHelper.requestDeployTransaction( zkSync, _deployBridgeImplementationFee, l2BridgeImplementationBytecodeHash, "", // Empty constructor data _factoryDeps // All factory deps are needed for L2 bridge ); (...) // Deploy L2 bridge proxy contract l2Bridge = BridgeInitializationHelper.requestDeployTransaction( zkSync, _deployBridgeProxyFee, l2BridgeProxyBytecodeHash, l2BridgeProxyConstructorData, // No factory deps are needed for L2 bridge proxy, because it is already passed in previous step new bytes[](0) );
to:
// Deploy L2 bridge implementation contract address bridgeImplementationAddr = BridgeInitializationHelper.requestDeployTransaction( zkSync, _deployBridgeImplementationFee, L2ContractHelper.hashL2Bytecode(_factoryDeps[0]), "", // Empty constructor data _factoryDeps // All factory deps are needed for L2 bridge ); (...) // Deploy L2 bridge proxy contract l2Bridge = BridgeInitializationHelper.requestDeployTransaction( zkSync, _deployBridgeProxyFee, L2ContractHelper.hashL2Bytecode(_factoryDeps[1]), l2BridgeProxyConstructorData, // No factory deps are needed for L2 bridge proxy, because it is already passed in previous step new bytes[](0) );
_depositFunds
Variable balanceAfter
is used only once, which means it doesn't need to be declared at all.
Below code snippet can be changed from: File: ethereum/contracts/bridge/L1ERC20Bridge.sol
uint256 balanceAfter = _token.balanceOf(address(this)); return balanceAfter - balanceBefore;
to:
return _token.balanceOf(address(this)) - balanceBefore;
claimFailedDeposit
Variable proofValid
is used only once, which means it doesn't need to be declared at all.Below code snippet can be changed from: File: ethereum/contracts/bridge/L1ERC20Bridge.sol
bool proofValid = zkSync.proveL1ToL2TransactionStatus( _l2TxHash, _l2BatchNumber, _l2MessageIndex, _l2TxNumberInBatch, _merkleProof, TxStatus.Failure ); require(proofValid, "yn");
to:
require(zkSync.proveL1ToL2TransactionStatus( _l2TxHash, _l2BatchNumber, _l2MessageIndex, _l2TxNumberInBatch, _merkleProof, TxStatus.Failure ), "yn");
finalizeWithdrawal
Variable success
is used only once, which means it doesn't need to be declared at all.Below code snippet can be changed from: File: ethereum/contracts/bridge/L1ERC20Bridge.sol
bool success = zkSync.proveL2MessageInclusion(_l2BatchNumber, _l2MessageIndex, l2ToL1Message, _merkleProof); require(success, "nq");
to:
require(zkSync.proveL2MessageInclusion(_l2BatchNumber, _l2MessageIndex, l2ToL1Message, _merkleProof), "nq");
l2TokenAddress
Variables constructorInputHash
and salt
are used only once, which means they don't need to be declared at all.
Below code snippet can be changed from:
File: ethereum/contracts/bridge/L1ERC20Bridge.sol
bytes32 constructorInputHash = keccak256(abi.encode(address(l2TokenBeacon), "")); bytes32 salt = bytes32(uint256(uint160(_l1Token))); return L2ContractHelper.computeCreate2Address(l2Bridge, salt, l2TokenProxyBytecodeHash, constructorInputHash);
to:
return L2ContractHelper.computeCreate2Address(l2Bridge, bytes32(uint256(uint160(_l1Token))), l2TokenProxyBytecodeHash, keccak256(abi.encode(address(l2TokenBeacon), "")));
L1ERC20Bridge.sol
File: ethereum/contracts/bridge/L1ERC20Bridge.sol
return balanceAfter - balanceBefore;
Since balanceAfter
contains token's balance after the transfer, and balanceBefore
contains token's balance before the transfer, we know, that balanceAfter >= balanceBefore
, thus balanceAfter - balanceBefore
will never underflow and can be unchecked.
amount
is greater than zero before performing safeTransfer
in L1ERC20Bridge.sol
File: ethereum/contracts/bridge/L1ERC20Bridge.sol
IERC20(l1Token).safeTransfer(l1Receiver, amount);
safeTransfer
'ing when amount = 0
is a waste of gas, since there will be no funds transferred.
To save gas, check if amount
is greater than 0, before calling safeTransfer
.
offset
in the last call of UnsafeBytes.readUint256
in L1ERC20Bridge.sol
A simple test in Remix IDE has been created, to compare gas usage of assigning more than one value from function call:
function getUints() public pure returns (uint, uint) {return (1, 2);} function getAll() public view { uint a; uint b; (a, b) = getUints(); uint g = gasleft(); (a, b) = getUints(); console.log(g - gasleft()); } function getOne() public view { uint a; uint b; (a, b) = getUints(); uint g = gasleft(); (a, ) = getUints(); console.log(g - gasleft()); }
getOne()
costs 74 gas, while getAll()
costs 82 gas. This implies, that we shouldn't assign every parameter from function call if it's not needed.
In the last call of UnsafeBytes.readUint256(_l2ToL1message, offset)
(line 336):
File: ethereum/contracts/bridge/L1ERC20Bridge.sol
(amount, offset) = UnsafeBytes.readUint256(_l2ToL1message, offset);
we won't need offset
anymore, thus we can change line 336 to: (amount, ) = UnsafeBytes.readUint256(_l2ToL1message, offset);
.
_verifyDepositLimit
in L1ERC20Bridge.sol
File: ethereum/contracts/bridge/L1ERC20Bridge.sol
} else { require(totalDepositedAmountPerUser[_l1Token][_depositor] + _amount <= limitData.depositCap, "d1"); totalDepositedAmountPerUser[_l1Token][_depositor] += _amount; }
We don't need to add amount
twice - firstly - to check require
statement, and secondly - to increase totalDepositedAmountPerUser[_l1Token][_depositor]
.
We can change the order of require
to do so:
} else { totalDepositedAmountPerUser[_l1Token][_depositor] += _amount; require(totalDepositedAmountPerUser[_l1Token][_depositor] <= limitData.depositCap, "d1"); }
If totalDepositedAmountPerUser[_l1Token][_depositor] + _amount
exceeds limitData.depositCap
, we will still revert.
To prove, that this will indeed save some gas, a minimalized code-snippet in Remix IDE has been prepared:
mapping(address => mapping(address => uint256)) public totalDepositedAmountPerUser; function current(uint _amount) public { uint g = gasleft(); require(totalDepositedAmountPerUser[msg.sender][msg.sender] + _amount <= 1000, "d1"); totalDepositedAmountPerUser[msg.sender][msg.sender] += _amount; console.log(g - gasleft()); } function afterFix(uint _amount) public { uint g = gasleft(); totalDepositedAmountPerUser[msg.sender][msg.sender] += _amount; require(totalDepositedAmountPerUser[msg.sender][msg.sender] <= 1000, "d1"); console.log(g - gasleft()); }
Function current()
uses 5895 gas, while function afterFix()
uses 5707, which implies, that proposed fix is better optimalized for a gas usage.
LibMap.sol
get
Variables mapValue
and bitOffset
are used only once, which means they don't need to be declared at all.
Below code snippet can be changed from:
File: ethereum/contracts/zksync/libraries/LibMap.sol
uint256 mapValue = _map.map[_index / 8]; // First three bits of the original `_index` denotes the position of the uint32 in that slot. // So, '(_index & 7) * 32' is done to find the bit position of the uint32 in that storage slot. uint256 bitOffset = (_index & 7) * 32; // Shift the bits to the right and retrieve the uint32 value. result = uint32(mapValue >> bitOffset);
to:
result = uint32(_map.map[_index / 8] >> ( (_index & 7) * 32));
set
Variables oldValue
and nexValueXorOldValue
are used only once, which means they don't need to be declared at all.
Below code snippet can be changed from:
File: ethereum/contracts/zksync/libraries/LibMap.sol
uint32 oldValue = uint32(mapValue >> bitOffset); // Calculate the XOR of the new value and the existing value. uint256 newValueXorOldValue = uint256(oldValue ^ _value); // Finally, we XOR the slot with the XOR of the new value and the existing value, // shifted to its proper position. The XOR operation will effectively replace the old value with the new value. _map.map[mapIndex] = (newValueXorOldValue << bitOffset) ^ mapValue;
to:
_map.map[mapIndex] = (uint256(uint32(mapValue >> bitOffset) ^ _value) << bitOffset) ^ mapValue;
Merkle.sol
While bot race reported every division by powers of two in a form: A / 2
, it misses an instance, which looks like: A /= 2
.
File: ethereum/contracts/zksync/libraries/Merkle.sol
_index /= 2;
It should be changed to: _index = _index >> 1;
Config.sol
.While constants have known value, to save more gas, calculate their values before compile-time.
File: ethereum/contracts/zksync/Config.sol
uint256 constant MAX_L2_TO_L1_LOGS_COMMITMENT_BYTES = 4 + L2_TO_L1_LOG_SERIALIZE_SIZE * 512 uint256 constant MAX_INITIAL_STORAGE_CHANGES_COMMITMENT_BYTES = 4 + INITIAL_STORAGE_CHANGE_SERIALIZE_SIZE * 4765; uint256 constant MAX_REPEATED_STORAGE_CHANGES_COMMITMENT_BYTES = 4 + REPEATED_STORAGE_CHANGE_SERIALIZE_SIZE * 7564;
pushBack
in PriorityQueue.sol
can be optimizedFile: ethereum/contracts/zksync/libraries/PriorityQueue.sol
function pushBack(Queue storage _queue, PriorityOperation memory _operation) internal { // Save value into the stack to avoid double reading from the storage uint256 tail = _queue.tail; _queue.data[tail] = _operation; _queue.tail = tail + 1; }
Above function can be optimized by utilizing post-incrementing feature. It will allow to remove tail
variable. Function won't be creating and reading from that variable:
function pushBack(Queue storage _queue, PriorityOperation memory _operation) internal { // Save value into the stack to avoid double reading from the storage _queue.data[_queue.tail++] = _operation; }
DiamondInit.sol
File: ethereum/contracts/zksync/DiamondInit.sol
// While this does not provide a protection in the production, it is needed for local testing // Length of the L2Log encoding should not be equal to the length of other L2Logs' tree nodes preimages assert(L2_TO_L1_LOG_SERIALIZE_SIZE != 2 * 32);
According to above code, assert(L2_TO_L1_LOG_SERIALIZE_SIZE != 2 * 32);
is redundant and can be removed in production, since this line of code is dedicated for local testing only.
This can be confirmed in file ethereum/contracts/zksync/Config.sol
, where constant L2_TO_L1_LOG_SERIALIZE_SIZE
is defined as 88
. Since this is a constant value, its value will never change, thus this assert
is unnecessary and can be removed.
_encoded
is calculated twice in validateL1ToL2Transaction
in TransactionValidator.sol
.Calculating length is gas-costly operation. It's better to calculate it once and store it in local variable.
File: ethereum/contracts/zksync/libraries/TransactionValidator.sol
uint256 l2GasForTxBody = getTransactionBodyGasLimit( _transaction.gasLimit, _transaction.gasPerPubdataByteLimit, _encoded.length ); (...) require( getMinimalPriorityTransactionGasLimit( _encoded.length, _transaction.factoryDeps.length, _transaction.gasPerPubdataByteLimit ) <= _transaction.gasLimit, "up" );
_encoded.length
is being calculated twice - in line 26 and line 38.
type(uintX).max
in TransactionValidator.sol
When you use type(uintX).max - it may result in higher gas costs because it involves a runtime operation to calculate the type(uintX).max
at runtime. This calculation is performed every time the expression is evaluated.
To save gas, it is recommended to use constants to represent the maximum value. Declaration of a constant with the maximum value means that the value is known at compile-time and does not require any runtime calculations.
File: ethereum/contracts/zksync/libraries/TransactionValidator.sol
require(_transaction.from <= type(uint16).max, "ua"); require(_transaction.to <= type(uint160).max, "ub"); require(_transaction.paymaster == 0, "uc"); require(_transaction.value == 0, "ud"); require(_transaction.reserved[0] == 0, "ue"); require(_transaction.reserved[1] <= type(uint160).max, "uf");
getMinimalPriorityTransactionGasLimit
in TransactionValidator.sol
File: ethereum/contracts/zksync/libraries/TransactionValidator.sol
costForComputation = L1_TX_INTRINSIC_L2_GAS; (...) costForComputation += Math.ceilDiv(_encodingLength * L1_TX_DELTA_544_ENCODING_BYTES, 544); (...) costForComputation += _numberOfFactoryDependencies * L1_TX_DELTA_FACTORY_DEPS_L2_GAS; (...) costForComputation = Math.max(costForComputation, L1_TX_MIN_L2_GAS_BASE); (...) costForPubdata = L1_TX_INTRINSIC_PUBDATA * _l2GasPricePerPubdata; (...) costForPubdata += _numberOfFactoryDependencies * L1_TX_DELTA_FACTORY_DEPS_PUBDATA * _l2GasPricePerPubdata;
There's no need to perform multiple costForComputation +=
, when it can be calculated at once:
costForComputation = L1_TX_INTRINSIC_L2_GAS + Math.ceilDiv(_encodingLength * L1_TX_DELTA_544_ENCODING_BYTES, 544) + _numberOfFactoryDependencies * L1_TX_DELTA_FACTORY_DEPS_L2_GAS; costForComputation = Math.max(costForComputation, L1_TX_MIN_L2_GAS_BASE);
The whole function can be rewritten to:
function getMinimalPriorityTransactionGasLimit( uint256 _encodingLength, uint256 _numberOfFactoryDependencies, uint256 _l2GasPricePerPubdata ) internal pure returns (uint256) { return Math.max(L1_TX_INTRINSIC_L2_GAS + Math.ceilDiv(_encodingLength * L1_TX_DELTA_544_ENCODING_BYTES, 544) + _numberOfFactoryDependencies * L1_TX_DELTA_FACTORY_DEPS_L2_GAS, L1_TX_MIN_L2_GAS_BASE) + (L1_TX_INTRINSIC_PUBDATA * _l2GasPricePerPubdata + _numberOfFactoryDependencies * L1_TX_DELTA_FACTORY_DEPS_PUBDATA * _l2GasPricePerPubdat); }
TransactionValidator.sol
getOverheadForTransaction
Variables txSlotOverhead
, overheadForLength
, numerator
and denominator
are used only once, which means they don't need to be declared at all.
Below code snippet can be changed from: File: ethereum/contracts/zksync/libraries/TransactionValidator.sol
uint256 txSlotOverhead = Math.ceilDiv(batchOverheadGas, MAX_TRANSACTIONS_IN_BATCH); batchOverheadForTransaction = Math.max(batchOverheadForTransaction, txSlotOverhead); // The overhead for occupying the bootloader memory can be derived from encoded_len uint256 overheadForLength = Math.ceilDiv(_encodingLength * batchOverheadGas, BOOTLOADER_TX_ENCODING_SPACE); batchOverheadForTransaction = Math.max(batchOverheadForTransaction, overheadForLength); (...) uint256 numerator = batchOverheadGas * _totalGasLimit + L2_TX_MAX_GAS_LIMIT; uint256 denominator = L2_TX_MAX_GAS_LIMIT + batchOverheadGas; overheadForGas = (numerator - 1) / denominator;
to:
batchOverheadForTransaction = Math.max(batchOverheadForTransaction, Math.ceilDiv(batchOverheadGas, MAX_TRANSACTIONS_IN_BATCH)); // The overhead for occupying the bootloader memory can be derived from encoded_len batchOverheadForTransaction = Math.max(batchOverheadForTransaction, Math.ceilDiv(_encodingLength * batchOverheadGas, BOOTLOADER_TX_ENCODING_SPACE)); (...) overheadForGas = (batchOverheadGas * _totalGasLimit + L2_TX_MAX_GAS_LIMIT - 1) / (L2_TX_MAX_GAS_LIMIT + batchOverheadGa);
Getters.sol
isFacetFreezable
Variables selectorsArrayLen
and selector0
are used only once, which means they don't need to be declared at all.Below code snippet can be changed from: File: ethereum/contracts/zksync/facets/Getters.sol
uint256 selectorsArrayLen = ds.facetToSelectors[_facet].selectors.length; if (selectorsArrayLen != 0) { bytes4 selector0 = ds.facetToSelectors[_facet].selectors[0]; isFreezable = ds.selectorToFacet[selector0].isFreezable;
to:
if (ds.facetToSelectors[_facet].selectors.length != 0) { isFreezable = ds.selectorToFacet[ds.facetToSelectors[_facet].selectors[0]].isFreezable;
Diamond.sol
diamondCut
Variables facet
and isFacetFreezable
are used only once, which means they don't need to be declared at all.
Below code snippet can be changed from: File: ethereum/contracts/zksync/libraries/Diamond.sol
Action action = facetCuts[i].action; address facet = facetCuts[i].facet; bool isFacetFreezable = facetCuts[i].isFreezable; bytes4[] memory selectors = facetCuts[i].selectors; require(selectors.length > 0, "B"); // no functions for diamond cut if (action == Action.Add) { _addFunctions(facet, selectors, isFacetFreezable); } else if (action == Action.Replace) { _replaceFunctions(facet, selectors, isFacetFreezable); } else if (action == Action.Remove) { _removeFunctions(facet, selectors);
to:
Action action = facetCuts[i].action; bytes4[] memory selectors = facetCuts[i].selectors; require(selectors.length > 0, "B"); // no functions for diamond cut if (action == Action.Add) { _addFunctions(facetCuts[i].facet, selectors, facetCuts[i].isFreezable); } else if (action == Action.Replace) { _replaceFunctions(facetCuts[i].facet, selectors, facetCuts[i].isFreezable); } else if (action == Action.Remove) { _removeFunctions(facetCuts[i].facet, selectors);
_saveFacetIfNew
Variable selectorsLength
is used only once, which means it doesn't need to be declared at all.Below code snippet can be changed from: File: ethereum/contracts/zksync/libraries/Diamond.sol
uint256 selectorsLength = ds.facetToSelectors[_facet].selectors.length; // If there are no selectors associated with facet then save facet as new one if (selectorsLength == 0) {
to:
if (ds.facetToSelectors[_facet].selectors.length == 0) {
_addOneFunction
Variable selector0
is used only once, which means it doesn't need to be declared at all.Below code snippet can be changed from: File: ethereum/contracts/zksync/libraries/Diamond.sol
bytes4 selector0 = ds.facetToSelectors[_facet].selectors[0]; require(_isSelectorFreezable == ds.selectorToFacet[selector0].isFreezable, "J1");
to:
require(_isSelectorFreezable == ds.selectorToFacet[ds.facetToSelectors[_facet].selectors[0]].isFreezable, "J1");
diamondCut()
from Diamond.sol
can be redesigned to revert earlierFile: ethereum/contracts/zksync/libraries/Diamond.sol
101: Action action = facetCuts[i].action; 102: address facet = facetCuts[i].facet; 103: bool isFacetFreezable = facetCuts[i].isFreezable; 104: bytes4[] memory selectors = facetCuts[i].selectors; 105: 106: require(selectors.length > 0, "B"); // no functions for diamond cut
You can check selectors' length ealier. If their length is 0, then function will revert without wasting gas on executing lines: 101-103. Change above code to:
bytes4[] memory selectors = facetCuts[i].selectors; require(selectors.length > 0, "B"); // no functions for diamond cut Action action = facetCuts[i].action; address facet = facetCuts[i].facet; bool isFacetFreezable = facetCuts[i].isFreezable;
Diamond.sol
, move some require
on top of functionFunctions: _addFunctions
, _replaceFunctions
, removeFunctions
, firstly gets the pointer to the diamond storage: DiamondStorage storage ds = getDiamondStorage();
and then, they perform a check, if user passed a proper _facet
address. Those lines can, however, be swapped.
If user provides incorrect _facet
w then function should immediately revert. Afterwards, it should get the pointer to the diamond storage.
In _addFunctions()
change:
DiamondStorage storage ds = getDiamondStorage(); require(_facet != address(0), "G"); // facet with zero address cannot be added
to:
require(_facet != address(0), "G"); // facet with zero address cannot be added DiamondStorage storage ds = getDiamondStorage();
In _replaceFunctions
change:
DiamondStorage storage ds = getDiamondStorage(); require(_facet != address(0), "K"); // cannot replace facet with zero address
to:
require(_facet != address(0), "K"); // cannot replace facet with zero address DiamondStorage storage ds = getDiamondStorage();
In _removeFunctions
change:
DiamondStorage storage ds = getDiamondStorage(); require(_facet == address(0), "a1"); // facet address must be zero
to:
require(_facet == address(0), "a1"); // facet address must be zero DiamondStorage storage ds = getDiamondStorage();
Diamond.sol
in _initializeDiamondCut()
, order of require
can be changedFile: ethereum/contracts/zksync/libraries/Diamond.sol
require(data.length == 32, "lp"); require(abi.decode(data, (bytes32)) == DIAMOND_INIT_SUCCESS_RETURN_VALUE, "lp1");
The order of require
can be changed, firstly check if abi.decode
is DIAMOND_INIT_SUCCESS_RETURN_VALUE
, then check its length:
require(abi.decode(data, (bytes32)) == DIAMOND_INIT_SUCCESS_RETURN_VALUE, "lp1"); require(data.length == 32, "lp");
It will save some gas in multiple of scenario:
data.length < 32
- decoding will revert and there will be no need to execute require(data.length == 32, "lp");
data.length > 32
, but first 32 bytes of data
are not DIAMOND_INIT_SUCCESS_RETURN_VALUE
- comparison will revert and there will be no need to execute require(data.length == 32, "lp");
Mailbox.sol
_proveL2LogInclusion
Variables calculatedRootHash
and actualRootHash
are used only once, which means they don't need to be declared at all.
Below code snippet can be changed from: File: ethereum/contracts/zksync/facets/Mailbox.sol
bytes32 calculatedRootHash = Merkle.calculateRoot(_proof, _index, hashedLog); bytes32 actualRootHash = s.l2LogsRootHashes[_batchNumber]; return actualRootHash == calculatedRootHash;
to:
return s.l2LogsRootHashes[_batchNumber] == Merkle.calculateRoot(_proof, _index, hashedLog);
l2TransactionBaseCost
Variable l2GasPrice
is used only once, which means it doesn't need to be declared at all.Below code snippet can be changed from: File: ethereum/contracts/zksync/facets/Mailbox.sol
uint256 l2GasPrice = _deriveL2GasPrice(_gasPrice, _l2GasPerPubdataByteLimit); return l2GasPrice * _l2GasLimit;
to:
return _deriveL2GasPrice(_gasPrice, _l2GasPerPubdataByteLimit) * _l2GasLimit;
_deriveL2GasPrice
Variables pubdataPriceETH
and minL2GasPriceETH
are used only once, which means they don't need to be declared at all.
Below code snippet can be changed from: File: ethereum/contracts/zksync/facets/Mailbox.sol
uint256 pubdataPriceETH = L1_GAS_PER_PUBDATA_BYTE * _l1GasPrice; uint256 minL2GasPriceETH = (pubdataPriceETH + _gasPricePerPubdata - 1) / _gasPricePerPubdata; return Math.max(FAIR_L2_GAS_PRICE, minL2GasPriceETH);
to:
return Math.max(FAIR_L2_GAS_PRICE, (L1_GAS_PER_PUBDATA_BYTE * _l1GasPrice + _gasPricePerPubdata - 1) / _gasPricePerPubdata);
_requestL2Transaction
Variables expirationTimestamp
, txId
, baseCost
are used only once, which means they don't need to be declared at all.
Below code snippet can be changed from: File: ethereum/contracts/zksync/facets/Mailbox.sol
uint64 expirationTimestamp = uint64(block.timestamp + PRIORITY_EXPIRATION); // Safe to cast uint256 txId = s.priorityQueue.getTotalPriorityTxs(); (...) uint256 baseCost = params.l2GasPrice * _l2GasLimit; require(msg.value >= baseCost + _l2Value, "mv"); // The `msg.value` doesn't cover the transaction cost (...) params.sender = _sender; params.txId = txId; params.l2Value = _l2Value; params.contractAddressL2 = _contractAddressL2; params.expirationTimestamp = expirationTimestamp; params.l2GasLimit = _l2GasLimit; params.l2GasPricePerPubdata = _l2GasPerPubdataByteLimit; params.valueToMint = msg.value; params.refundRecipient = refundRecipient;
to:
require(msg.value >= params.l2GasPrice * _l2GasLimit + _l2Value, "mv"); // The `msg.value` doesn't cover the transaction cost (...) params.sender = _sender; params.txId = s.priorityQueue.getTotalPriorityTxs(); params.l2Value = _l2Value; params.contractAddressL2 = _contractAddressL2; params.expirationTimestamp = uint64(block.timestamp + PRIORITY_EXPIRATION); params.l2GasLimit = _l2GasLimit; params.l2GasPricePerPubdata = _l2GasPerPubdataByteLimit; params.valueToMint = msg.value; params.refundRecipient = refundRecipient;
s.totalDepositedAmountPerUser[_depositor]
in _verifyDepositLimit()
in Mailbox.sol
File: ethereum/contracts/zksync/facets/Mailbox.sol
require(s.totalDepositedAmountPerUser[_depositor] + _amount <= limitData.depositCap, "d2"); s.totalDepositedAmountPerUser[_depositor] += _amount;
There's no need to access s.totalDepositedAmountPerUser[_depositor]
twice and perform addition two times. Much cheaper solution would be to cache s.totalDepositedAmountPerUser[_depositor] + _amount
.
Code can be rewritten to:
uint256 totalDepositedAmountPerUserAndAmount = s.totalDepositedAmountPerUser[_depositor] + _amount; require(totalDepositedAmountPerUserAndAmount <= limitData.depositCap, "d2");
we've cached s.totalDepositedAmountPerUser[_depositor] + _amount
, and then later checked if it does not exceed limit. If it does, function will revert.
Mailbox.sol
can be uncheckedIf, for any reason recommendation for G-31 won't be implemented, another gas optimization would be insert line 280: s.totalDepositedAmountPerUser[_depositor] += _amount;
into unchecked block.
Since require(s.totalDepositedAmountPerUser[_depositor] + _amount <= limitData.depositCap, "d2");
guarantees, that s.totalDepositedAmountPerUser[_depositor] + _amount
won't overflow, we can perform 2nd addition as unchecked: unchecked {s.totalDepositedAmountPerUser[_depositor] += _amount;}
_requestL2Transaction
in Mailbox.sol
can be either removed or unchecked.File: ethereum/contracts/zksync/facets/Mailbox.sol
uint64 expirationTimestamp = uint64(block.timestamp + PRIORITY_EXPIRATION); // Safe to cast
Constant PRIORITY_EXPIRATION
is defined in ethereum/contracts/zksync/Config.sol
as:
/// NOTE: The constant is set to zero for the Alpha release period uint256 constant PRIORITY_EXPIRATION = 0 days;
This addition is then redundant (since we're adding 0). However, if for further reference you want to keep this code as it is (e.g. new contract will be deployed with different Alpha release period), make this operation unchecked
at least. Since PRIORITY_EXPIRATION
is a constant value, known during compiling time, and block.timestamp
is impossible to overflow, it's safe to assume, that block.timestamp + PRIORITY_EXPIRATION
will never overflow. The max value of uint64
converted into timestamp is Sun Jul 21 2554
. It's almost impossible that block.timestamp + PRIORITY_EXPIRATION
will ever exceed that number (assuming that PRIORITY_EXPIRATION
will be set to some reasonable value).
offset
in the last call of UnsafeBytes.readUint256()
in Mailbox.sol
A simple test in Remix IDE has been created, to compare gas usage of assigning more than one value from function call:
function getUints() public pure returns (uint, uint) {return (1, 2);} function getAll() public view { uint a; uint b; (a, b) = getUints(); uint g = gasleft(); (a, b) = getUints(); console.log(g - gasleft()); } function getOne() public view { uint a; uint b; (a, b) = getUints(); uint g = gasleft(); (a, ) = getUints(); console.log(g - gasleft()); }
getOne()
costs 74 gas, while getAll()
costs 82 gas. This implies, that we shouldn't assign every parameter from function call if it's not needed.
In the last call of UnsafeBytes.readUint256(_message, offset)
(line 427):
File: ethereum/contracts/zksync/facets/Mailbox.sol
(amount, offset) = UnsafeBytes.readUint256(_message, offset);
we won't need offset
anymore, thus we can change line 427 to: (amount, ) = UnsafeBytes.readUint256(_message, offset);
.
_commitBatchesWithSystemContractsUpgrade()
in Executor.sol
can be optimizedFile: ethereum/contracts/zksync/facets/Executor.sol
for (uint256 i = 0; i < _newBatchesData.length; i = i.uncheckedInc()) { // The upgrade transaction must only be included in the first batch. bytes32 expectedUpgradeTxHash = i == 0 ? _systemContractUpgradeTxHash : bytes32(0);
The for
loop iterates from 0 to _newBatchesData.length
. In every iterations (at line 243), it checks if i == 0
. However, this condition occurs only during the first loop iteration.
This means, that it will be better to divide the loop into two smaller loops:
for (uint256 i = 0; i < 1; i = i.uncheckedInc()) { bytes32 expectedUpgradeTxHash = _systemContractUpgradeTxHash; (...)
for (uint256 i = 1; i < _newBatchesData.length; i = i.uncheckedInc()) { bytes32 expectedUpgradeTxHash = bytes32(0); (...)
That way, we will be performing i == 0
check only during the first loop iteration, thus this comparison will be done at least once.
Moreover, to better optimize these two loops, please make sure to declare i
counter outside that loops. That way, you won't be redeclaring i
inside the 2nd loop:
uint256 i = 0; for (i; i < 1; i = i.uncheckedInc()) { bytes32 expectedUpgradeTxHash = _systemContractUpgradeTxHash; (...) } for (i; i < _newBatchesData.length; i = i.uncheckedInc()) { bytes32 expectedUpgradeTxHash = bytes32(0); (...) }
Additionally, as you can notice, expectedUpgradeTxHash
is used only once, so it does not need to be declared at all. It can be used directly:
_lastCommittedBatchData = _commitOneBatch( _lastCommittedBatchData, _newBatchesData[i], _systemContractUpgradeTxHash );
_lastCommittedBatchData = _commitOneBatch( _lastCommittedBatchData, _newBatchesData[i], bytes32(0) );
Executor.sol
can be uncheckedFile: ethereum/contracts/zksync/facets/Executor.sol
require(block.timestamp - COMMIT_TIMESTAMP_NOT_OLDER <= batchTimestamp, "h1"); // New batch timestamp is too small require(lastL2BlockTimestamp <= block.timestamp + COMMIT_TIMESTAMP_APPROXIMATION_DELTA, "h2"); // The last L2 block timestamp is too big
Since COMMIT_TIMESTAMP_NOT_OLDER
and COMMIT_TIMESTAMP_APPROXIMATION_DELTA
are constant values, which are known before compilation time, it's reasonable to assume, that they are chosen in a way, which:
block.timestamp >= COMMIT_TIMESTAMP_NOT_OLDER
block.timestamp + COMMIT_TIMESTAMP_APPROXIMATION_DELTA
won't overflow.
If that's the case - those two lines can be unchecked.require
checks on top of function in Executor.sol
File: ethereum/contracts/zksync/facets/Executor.sol
uint256 currentTotalBatchesVerified = s.totalBatchesVerified; uint256 committedBatchesLength = _committedBatches.length; // Save the variable from the storage to memory to save gas VerifierParams memory verifierParams = s.verifierParams; // Initialize the array, that will be used as public input to the ZKP uint256[] memory proofPublicInput = new uint256[](committedBatchesLength); // Check that the batch passed by the validator is indeed the first unverified batch require(_hashStoredBatchInfo(_prevBatch) == s.storedBatchHashes[currentTotalBatchesVerified], "t1");
require(_hashStoredBatchInfo(_prevBatch) == s.storedBatchHashes[currentTotalBatchesVerified], "t1");
can be moved up:
uint256 currentTotalBatchesVerified = s.totalBatchesVerified; // Check that the batch passed by the validator is indeed the first unverified batch require(_hashStoredBatchInfo(_prevBatch) == s.storedBatchHashes[currentTotalBatchesVerified], "t1"); uint256 committedBatchesLength = _committedBatches.length; // Save the variable from the storage to memory to save gas VerifierParams memory verifierParams = s.verifierParams; // Initialize the array, that will be used as public input to the ZKP uint256[] memory proofPublicInput = new uint256[](committedBatchesLength);
That way, when this check will revert, function won't waste gas on performing other instructions.
File ethereum/contracts/common/libraries/UncheckedMath.sol
is a library which implements two simple unchecked functions: uncheckedInc()
and uncheckedAdd()
.
Those functions are used across the whole code-base, mostly inside loops:
ethereum/contracts/zksync/facets/Executor.sol
209: for (uint256 i = 0; i < _newBatchesData.length; i = i.uncheckedInc()) { 241: for (uint256 i = 0; i < _newBatchesData.length; i = i.uncheckedInc()) { 263: for (uint256 i = 0; i < _nPriorityOps; i = i.uncheckedInc()) { 293: for (uint256 i = 0; i < nBatches; i = i.uncheckedInc()) { 330: for (uint256 i = 0; i < committedBatchesLength; i = i.uncheckedInc()) {
ethereum/contracts/zksync/facets/Mailbox.sol
395: for (uint256 i = 0; i < factoryDepsLen; i = i.uncheckedInc()) {
ethereum/contracts/zksync/libraries/Diamond.sol
100: for (uint256 i = 0; i < facetCutsLength; i = i.uncheckedInc()) { 138: for (uint256 i = 0; i < selectorsLength; i = i.uncheckedInc()) { 159: for (uint256 i = 0; i < selectorsLength; i = i.uncheckedInc()) { 179: for (uint256 i = 0; i < selectorsLength; i = i.uncheckedInc()) {
ethereum/contracts/zksync/facets/Getters.sol
182: for (uint256 i = 0; i < facetsLen; i = i.uncheckedInc()) {
ethereum/contracts/zksync/libraries/Merkle.sol
29: for (uint256 i; i < pathLength; i = i.uncheckedInc()) {
ethereum/contracts/common/AllowList.sol
62: for (uint256 i = 0; i < targetsLength; i = i.uncheckedInc()) { 96: for (uint256 i = 0; i < callersLength; i = i.uncheckedInc()) {
However, using library functions, instead of directly increasing i
in an unchecked
block is very gas ineffective.
To prove that, a simple test in Remix IDE was prepared:
function uncheckedInt() public view { uint gas = gasleft(); for (uint i = 0; i < 100; ) { require(i == i); unchecked {++i;} } console.log(gas - gasleft()); } function uncheckedIntLib() public view { uint gas = gasleft(); for (uint i = 0; i < 100; i = i.uncheckedInc()) {require(i == i); } console.log(gas - gasleft()); }
Function uncheckedInt()
costs 7442 gas, while uncheckedIntLib()
costs 11742 gas.
This implies, that every loop in a form:
for (uint256 i = 0; (...) i = i.uncheckedInc()) {
should be rewritten to:
for (uint256 i = 0; (...) i = i.uncheckedInc()) { (...) unchecked {++i;} }
There are two additional places in ethereum/contracts/zksync/facets/Executor.sol
, where the similar rule applies
ethereum/contracts/zksync/facets/Executor.sol
123: for (uint256 i = 0; i < emittedL2Logs.length; i = i.uncheckedAdd(L2_TO_L1_LOG_SERIALIZE_SIZE)) { 331: currentTotalBatchesVerified = currentTotalBatchesVerified.uncheckedInc();
In line 123, i = i + L2_TO_L1_LOG_SERIALIZE_SIZE
should also be unchecked.
In line 331, it should be: unchecked {++currentTotalBatchesVerified;}
if
condition in Executor.sol
File: ethereum/contracts/zksync/facets/Executor.sol
if (_proof.serializedProof.length > 0) { bool successVerifyProof = s.verifier.verify( proofPublicInput, _proof.serializedProof, _proof.recursiveAggregationInput ); require(successVerifyProof, "p"); // Proof verification fail } // #else bool successVerifyProof = s.verifier.verify( proofPublicInput, _proof.serializedProof, _proof.recursiveAggregationInput ); require(successVerifyProof, "p"); // Proof verification fail
In above code section, we're performing zkp verification twice.
For the first time - when if (_proof.serializedProof.length > 0)
.
And then, for the second time - in line 363.
This is redundant. Remove the whole if (_proof.serializedProof.length > 0)
block, because we're repeating s.verifier.verify()
call after that block for the second time.
Executor.sol
can be optimizedFile: ethereum/contracts/zksync/facets/Executor.sol
s.totalBatchesCommitted = _newLastBatch; // Reset the batch number of the executed system contracts upgrade transaction if the batch // where the system contracts upgrade was committed is among the reverted batches. if (s.l2SystemContractsUpgradeBatchNumber > _newLastBatch) { delete s.l2SystemContractsUpgradeBatchNumber; } emit BlocksRevert(s.totalBatchesCommitted, s.totalBatchesVerified, s.totalBatchesExecuted);
Since s.totalBatchesCommitted = _newLastBatch
, we can change event to:
emit BlocksRevert(_newLastBatch, s.totalBatchesVerified, s.totalBatchesExecuted);
DiamonProxy.sol
File: ethereum/contracts/zksync/DiamondProxy.sol
require(!diamondStorage.isFrozen || !facet.isFreezable, "q1");
!A || !B <=> !(A & B)
which means, it can be rewritten to:
require(!(diamondStorage.isFrozen && facet.isFreezable)), "q1");
#0 - 141345
2023-10-26T04:21:21Z
20 nc
The following are invalid or duplicate:
[G-01] Pre-calculate keccak256 x
[G-18] Precalculate constants x
[G-05] Unnecessary variable [G-07] Unnecessary variable [G-08] Unnecessary variable [G-11] Unnecessary variable [G-16] Unnecessary variable [G-24] Unnecessary variable [G-25] Unnecessary variable [G-26] Unnecessary variable [G-30] Unnecessary variable d
[G-06] Length of _factoryDeps is calculated multiple of times in [G-21] Length of _encoded is calculated twice d
[G-09] Check if amount is greater than zero before performing safeTransfer [G-13] Check if amount is greater than zero before performing safeTransfer i
[G-22] Use constants instead of type(uintX).max x
[G-29] order of require can be changed [G-37] Move some require d
[G-33] Addition can be either removed or unchecked. [G-36] won't underflow/overflow unchecked d
[G-34] Do not assign offset d
#1 - c4-pre-sort
2023-11-02T16:11:08Z
141345 marked the issue as sufficient quality report
#2 - c4-judge
2023-11-15T13:15:18Z
GalloDaSballo marked the issue as grade-b
#3 - lsaudit
2023-12-03T02:00:39Z
Hey @GalloDaSballo , thank you for granting me an access to the repo and judging the whole contest! I would like to, however, ask for reviewing some of my issues in the current gas report. As far as I can see, the pre-sort marked only duplicated issues, and the rest of my findings had been evaluated as NC collectively. I believe, that some of them, however, should be classified as R or L, instead of NC. Here's my reasoning and further explanation.
I've provided a way to refactor some part of the code-base, in a way, where there's no need to waste gas on some operations. I have provide a different implementation of the function. Shouldn't refactoring issue be considered as R instead of NC? Moreover, please notice, that because of my refactoring recommendations, we can avoid to read s.totalDepositedAmountPerUser[_depositor]
twice. AFAIK, reducing the number of reads of state variable is evaluated as Low in every gas report. Shouldn't this issue be evaluated as Low?
For the reference, please check: https://github.com/code-423n4/2023-10-zksync-findings/issues/996 where G-8, G-9, G-10 are evaluated as Low. In case of this issue, the impact is the same. My optimization reduces the number of readings of state variable and, additionally, reduces the number of additions, so, imho, it saves enough gas to be evaluated as L instead of NC.
This is not a duplicate. While it might had been treated as one, since one of my recommended mitigation step was to uncheck this operation.
However, please notice, that unchecking the operation was provided as an additional recommendation (whenever Alpha release period will be changed in the future).
The main recommendation (for the current codebase) is not a duplicate. The main crux of this issue is that the whole operation is redundant. Since PRIORITY_EXPIRATION
is set to 0, this line:
uint64 expirationTimestamp = uint64(block.timestamp + PRIORITY_EXPIRATION);
is basically, the same as:
uint64 expirationTimestamp = uint64(block.timestamp + 0);
thus it should be: uint64 expirationTimestamp = uint64(block.timestamp );
.
I see that in other reports, e.g.: https://github.com/code-423n4/2023-10-zksync-findings/issues/996 (G-15), that removing some redundant operations are evaluated as R. In my finding, + PRIORITY_EXPIRATION
is redundant.
I'd like to ask why this issue was evaluated as NC. I've provided a way to refactor the function. In my suggested implementation, almost 100% of the pushBack()
has been changed - refactoring issues are evaluated as R.
Moreover, the suggested recommendation allows to reduce the number of reads from the storage _queue.tail
variable. Reducing the number of reads are typically evaluated as Low (for the reference, please check: https://github.com/code-423n4/2023-10-zksync-findings/issues/996 where G-8, G-9, G-10 are evaluated as Low).
I think that this issue should be evaluated as Refactoring, because the structure of the code has been altered.
I've provided multiple of ways to optimize _commitBatchesWithSystemContractsUpgrade()
. Each of those idea (they are all different ones) could be treated as a separate NC. Since I've provided a multiple of ways to refactor the loop, I think this should be R, instead of NC.
This issue has been identified in multiple of previous Code4rena contests - it was also reported in the main report (selected for the report), for the reference:
May I ask why it's considered as invalid?
Firstly, I'd like to clarify, that by Unnecessary variable
, I meant Don't cache variable only used once
. I admit, that the name of my issues might have been confusing, however, I hoped that issues' description were clear enough to explain what I had meant by this title.
I don't agree that above issues are duplicates. Each of those findings required to manually work to confirm that the issue indeed occurs. Identification of those issues cannot be automated by bot and requires additional verification.
Moreover, I think it might be a little unfair, that when I see other reports that contain just 1-2 Don't cache variable only used once
issues and are evaluated as 1 x NC, while I've provided 10 examples, manually reviewed the whole code-base and all my findings were evaluated as 1 x NC too.
E.g. this report: https://github.com/code-423n4/2023-10-zksync-findings/issues/55 identified only three instances G-17.1, G-17.2, G-15 - and it got 2 x NC, while all my findings were evaluated as 1 x NC (where I have clearly identified more unnecessary variables).
Additionally, I've grouped each of these finding by file, e.g. [G-08]
contains all local variable used only once in L1WethBridge.sol
. I thought that it will be easier for the devs to read the report. That's why there are multiple of titles. But each of this issue is related to a different file. This results in identification of 40 redundant variables! I think it's a little unfair, that I managed to identify almost every redundant variables in the code-base, and then, my report is classified the same (1 x NC) as similar reports which reported just a few variables. This might encourage others to just report a few examples, instead of diving deeper into the code-base. I strongly believe, that my report (in the context of unnecessary variables) brings much more value then e.g. https://github.com/code-423n4/2023-10-zksync-findings/issues/55 . Mine identifies almost every redundant variable (and was evaluated as 1 x NC + 9 x D), while the later, with 3 issues only - as 2 x NC.
I think that this issue should be evaluated as Refactoring, because the structure of the code has been altered. I've provided a way to refactor code-base, so that there's no need waste gas on some operations.
In the current code-base, there are multiple of unnecessary reading of costForComputation
, because of +=
operator. I examined the flow of each operation and suggested, that it can be changed (to reduce the number of costForComputation
readings).
Since I have provide a different implementation of the function, shouldn't refactoring issue be considered as R instead of NC?
I've provided a way to refactor code-base, so that there's no need waste gas on some operations. Shouldn't refactoring issue be considered as R instead of NC? According to the provided Remix-IDE code snippet, this kind of optimization saves 191 gas. I think that the suggested change saves enough gas to evaluate this issue as more than NC.
Thank you in advance for resolving my doubts!
#4 - sathishpic22
2023-12-03T17:05:56Z
Hi @GalloDaSballo
The provided report appears to contain numerous ignoring and out-of-scope findings, leading to a low overall impact. Issues identified include repetition of titles, inclusion of findings for quantity rather than quality, and a significant number of invalid findings and assumptions. Most critically, many findings seem to overlook the fact that the changes suggested do not substantially impact the gas.
Please reassess this report. Thank you
#5 - lsaudit
2023-12-03T19:32:52Z
The provided report appears to contain numerous ignoring and out-of-scope findings, leading to a low overall impact. Issues identified include repetition of titles, inclusion of findings for quantity rather than quality, and a significant number of invalid findings and assumptions. Most critically, many findings seem to overlook the fact that the changes suggested do not substantially impact the gas.
Please reassess this report. Thank you
I don't wanna argue with you, and I won't. I just think that entering others people reports, saying that there are "numerous out of scope findings" and "invalid findings and assumptions" without listing them, and then, asking the judge to reassess the whole report - is extremely disrespectful to the judge's time. I understand valid criticism, but your comment lacks of any facts and examples. I'll only ask you to follow Post-judging QA guidelines: All comments should present fact-based evidence, rather than opinion.
As of "repetition of titles", I've explained that in the comment above. Each finding with the repeated title affects different file. Pre-sort have already grouped them into one category (as you can see from the pre-sort, there are multiple of dups in my report).
I'll refrain from further comments in this issue and wait for the judge response.
#6 - GalloDaSballo
2023-12-10T18:50:24Z
I've reviewed a part of the report and believe that the presorter is correct in mostly estimating findings as NC
I also believe that some penalty may have been applied by me due to repetitive titles
I always recommend the following:
Overall the report is not bad, but I will not change the scoring as I believe the presorter is in line with my assessment
#7 - c4-judge
2023-12-10T19:07:38Z
GalloDaSballo marked the issue as grade-a
#8 - c4-judge
2023-12-10T19:08:14Z
GalloDaSballo marked the issue as selected for report