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: 32/64
Findings: 2
Award: $1,050.37
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: HE1M
Also found by: 0xsomeone, AkshaySrivastav, Aymen0909, J4de, Koolex, Mohandes, bin2chen, brgltd, cccz, hals, ladboy233, max10afternoon, peanuts, rvierdiiev, shealtielanz, tsvetanovv, zzzitron
273.5673 USDC - $273.57
Users will lose their deposits if the transaction from L1->L2 fails and deposit limit is turned on in that duration.
When a user calls L1ERC20Bridge.deposit()
, _verifyDepositLimit()
will be called. _verifyDepositLimit()
checks whether the token has a deposit limitation or not. If it does not, return the function. If it does, append the amount deposited to the totalDepositedAmountPerUser
mapping.
function _verifyDepositLimit(address _l1Token, address _depositor, uint256 _amount, bool _claiming) internal { IAllowList.Deposit memory limitData = IAllowList(allowList).getTokenDepositLimitData(_l1Token); //@audit -- the function will return if there is no deposit limitation if (!limitData.depositLimitation) return; // no deposit limitation is placed for this token if (_claiming) { totalDepositedAmountPerUser[_l1Token][_depositor] -= _amount; } else { require(totalDepositedAmountPerUser[_l1Token][_depositor] + _amount <= limitData.depositCap, "d1"); // If there is a limitation, add the total deposited amount totalDepositedAmountPerUser[_l1Token][_depositor] += _amount; } }
//@audit The deposit struct in IAllowList.sol struct Deposit { bool depositLimitation; uint256 depositCap; }
When the transaction from L1-L2 fails, the user can call claimFailedDeposit()
to claim back the deposited amount. Likewise, the function calls _verifyDepositLimit()
and subtracts the deposited amount from the totalDepositedAmountPerUser
struct.
if (_claiming) { totalDepositedAmountPerUser[_l1Token][_depositor] -= _amount;
The issue arises when the deposit limit is turned off at first and then turned on later. For example, User A deposits 10000 DOGE tokens. Since there is no deposit Limit, _verifyDepositLimit()
will be skipped.
A second later, the deposit limit for DOGE is turned on and set to 1 million.
User A encounters a failed transaction from L1 to L2. He calls claimFailedDeposit()
which calls _verifyDepositLimit()
. Since the deposit limit is turned on now, the function runs and the first clause of the if statement is executed because _claiming
is true. The user will not get back any tokens because the mapping totalDepositedAmountPerUser
is zero and the function will result in underflow.
User A loses 10000 DOGE tokens.
//@audit Owner can turn the deposit limit on or off using this function in AllowList.sol function setDepositLimit(address _l1Token, bool _depositLimitation, uint256 _depositCap) external onlyOwner { tokenDeposit[_l1Token].depositLimitation = _depositLimitation; tokenDeposit[_l1Token].depositCap = _depositCap; }
Manual Review
Refacor the _verifyDepositLimit()
function in such a way that it still stores the amount deposited even if there is no deposit limit
Invalid Validation
#0 - c4-pre-sort
2023-10-31T14:56:05Z
bytes032 marked the issue as duplicate of #425
#1 - c4-pre-sort
2023-10-31T14:56:42Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-11-24T20:01:01Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: zkLotus
Also found by: K42, Sathish9098, catellatech, peanuts, pfapostol
776.8033 USDC - $776.80
Zksync era is a new version of the zksync network, which is an L2 that is built on top of Ethereum. In the context of zkSync, an era represents a period where features are implemented or upgraded. The solidity side of the protocol is mostly on the bridge part, where users can bridge their tokens or ETH from L1(Ethereum) to L2(Zksync) and vice versa. During the bridging process, zksync uses zero-knowledge proofs to validate transactions off-chain and submit a SNARK on chain (zero-knowledge Succinct Non-interactive ARgument of Knowledge) to ensure the correctness and security of those transaction. The proofing is done in the circuits part of the protocol using rust.
Looked through the solidity side of the protocol, mostly bridging, minting, sending transactions and gas calculations. Also looked through the bootloader contract
deposit()
in L1ERC20Bridge.sol. User must be in the special allowlist or the access mode is set to public in Allowlist.sol._depositFunds()
. The fund amount must be the same amount as the parameter._verifyDepositLimit()
_getDepositL2Calldata()
is called.IL2Bridge.finalizeDeposit
is called (in L2ERC20Bridge.sol)._deployL2Token()
_deployL2Token()
calls _deployBeaconProxy
and initializes a bridge in L2StandardERC20.sol with bridgeInitialize()
bridgeMint()
in L2StandardToken.requestL2Transaction()
is called in the zkSync Mailbox contract._requestL2Transaction()
is called (Take note that _requestL2Transaction can be called directly by the user)_requestL2Transaction()
calls _writePriorityOp()
_writePriorityOp()
serializes the L2 transaction through _serializeL2Transaction()
_serializeL2Transaction()
calls validateL1ToL2Transaction()
and pushes the transaction into the priority queue.commitBatches()
and executeBatch()
in Executor.soldeposit()
in L1WethBridge.sol_getDepositL2Calldata
is called which calls IL2Bridge.finalizeDeposit()
, there is no deploying of proxy and minting of tokensIL2Weth(l2WethAddress).depositTo
.claimFailedDeposit()
to withdraw their locked tokens.claimFailedDeposit()
calls proveL1ToL2TransactionStatus()
in Mailbox.sol contract.proveL1ToL2TransactionStatus()
gets the L2Log and calls _proveL2LogInclusion()
._proveL2LogInclusion()
calculates the root has and the actual root has and compares them with each other. If both hash are the same, that means the specific L2 log was sent to L2 in a specific L2 batch number.claimFailedDeposit()
function will verify the deposit limit through _verifyDepositLimit()
and transfer the token back to the userBridging ERC20 tokens from L2 to L1
withdraw()
is called on L2ERC20BridgebridgeBurn()
is called. The L2 ERC20 token is burned first._getL1WithdrawMessage()
is called to encode the messageL2ContractHelper.sendMessageToL1()
is called with the encoded message as the parameter.L2_MESSENGER.sendToL1()
to send the message to L1Bridging native tokens from L2 to L1
withdraw()
is called on L2WethBridgeIL2StandardToken(l2WethAddress).bridgeBurn
L2_ETH_ADDRESS.withdrawWithMessage{value: _amount}(l1Bridge, wethMessage);
Contract | Function | Explanation | Review |
---|---|---|---|
Allowlist | canCall | Whether the caller can call the specific function on target contract | Used in AllowListed senderCanCallFunction modifer in Bridges Contracts |
Allowlist | setAccessMode | Set the permission mode to call the target contract | onlyOwner, changes AccessMode (public, special access) |
Allowlist | setBatchAccessMode | Same as setAccessMode(), just with batches | Loops and calls _setAccessMode() |
Allowlist | setPermissionToCall | Give permission to call if access is restricted | onlyOwner, gives special access permission |
Allowlist | setBatchPermissionToCall | Same as setPermissionToCall, just with batches | Loops and calls _setPermissionToCall() |
Allowlist | setDepositLimit | Set deposit limit data for a L1 token | onlyOwner, checks if limit is active and sets limit |
Allowlist | getTokenDepositLimitData | Get deposit limit data of a token | view, returns deposit limit data |
Governance | getOperationState | Checks the operation state of the given id | view, returns operation state |
Governance | scheduleTransparent | Propose a fully transparent upgrade | onlyOwner, schedules operation with a delay, delay must be < minDelay |
Governance | scheduleShadow | Propose "shadow" upgrade | onlyOwner, gets _id instead of _operation, |
Governance | cancel | Cancel the scheduled operation | onlyOwnerOrSecurityCouncil, cancels the operation |
Governance | execute | Executes the scheduled operation | onlyOwnerOrSecurityCouncil, make sure time delay has passed |
Governance | executeInstant | Executes the scheduled operation instantly | onlySecurityCouncil , no need for time delay |
Governance | updateDelay | Changes the minimum timelock duration for future operations | onlySelf , must be called through execute (only contract can call) |
Governance | updateSecurityCouncil | Updates the address of the security council | onlySelf , must be called through execute (only contract can call) |
L1ERC20Bridge | deposit | Lock funds in the contract and sends the request | calls finalizeDeposit on L2 Bridge and requestL2Transaction in Mailbox |
L1ERC20Bridge | claimFailedDeposit | Withdraw funds that failed when finalizing on L2 | proveL1ToL2TransactionStatus, call proveL2MessageInclusion |
L1ERC20Bridge | finalizeWithdrawal | Finalize the withdrawal and release funds | called from L2 bridge, call proveL2MessageInclusion |
L1WethBridge | deposit | Deposits WETH in contract, contract unwraps WETH to ETH | different from normal bridge, directly call requestL2Transaction |
L1WethBridge | claimFailedDeposit | Withdraw funds that failed when finalizing on L2 | Not Supported |
L1WethBridge | finalizeWithdrawal | Finalize the withdrawal from L2 and release funds to L1 | called from L2 bridge, call extra isEthWithdrawalFinalized |
L2ERC20Bridge | finalizeDeposit | Finalize the deposit from L1 and mint funds on L2 | called in deposit in L1ERC20 Bridge, creates a beacon proxy L2 Token |
L2ERC20Bridge | withdraw | Burns funds on L2 and send message to L1 | Call L2ContractHelper.sendMessageToL1 |
L2StandardERC20 | bridgeMint | Mint tokens to a given account | onlyBridge, onlyL2Bridge can call |
L2StandardERC20 | bridgeBurn | Burn tokens from a given account | onlyBridge, onlyL2Bridge can call |
L2WethBridge | finalizeDeposit | Finalize the deposit from L1 and mint WETH on L2 | onlyL1Bridge can call |
L2WethBridge | withdraw | Withdraw WETH on L2 and send to L1 | must be WETH token, not native ETH (no payable modifier) |
L2Weth | bridgeMint | Mint tokens on L2 | Not Supported |
L2Weth | bridgeBurn | Burn tokens from a given account and send to L2 Bridge | onlyL2WethBridge can call, burns WETH, transfer ETH to bridge |
L2Weth | deposit | Deposit Ether to mint WETH | Special L2 Weth minted from native ETH |
L2Weth | withdraw | Burn WETH to get Ether back | Special L2 Weth burned for native ETH |
Mailbox | proveL2MessageInclusion | Prove that message was sent in a specific L2 batch number | view, used to finalize L1 withdrawals |
Mailbox | proveL2LogInclusion | Prove that log was sent in a specific L2 batch | view, Returns true or false, (success or failure) |
Mailbox | proveL1ToL2TransactionStatus | Prove that the L1 -> L2 transaction was processed | pure, used to claim failed deposits |
Mailbox | l2TransactionBaseCost | Estimate ETH cost of requestion execution of L2 from L1 | derives the L2 Gas price |
Mailbox | finalizeEthWithdrawal | Finalize ETH withdrawal and release funds | sets isEthWithdrawalFinalized to true, calls _withdrawFunds |
Mailbox | requestL2Transaction | Prove that L1->L2 transaction was processed | payable, gets the canonicalTxhash (txId is the nonce) |
Allowlist
File: AllowList.sol 129: function setDepositLimit(address _l1Token, bool _depositLimitation, uint256 _depositCap) external onlyOwner { 130: tokenDeposit[_l1Token].depositLimitation = _depositLimitation; 131: tokenDeposit[_l1Token].depositCap = _depositCap; 132: }
Governance
requestL2Transaction()
can be called directly without going through the L1ERC20 Bridge.IL2Bridge.finalizeDeposit()
is not called, which means that calling requestL2Transaction()
directly does not entail bridging tokens.Contract | Actors | Purpose | Risk |
---|---|---|---|
Allowlist | Owner | Set access mode, deposit limit | Yes |
Governance | Owner | Schedule and execute operations | Yes |
Governance | Security Council | Execute operations, bypass operation time limit | Yes |
Governance | Contract | UpdateDelay, update security council | Nil |
L1ERC20Bridge | User | Call deposit functions, depending on availability of access mode | No |
L1WethBridge | User | Call deposit functions, depending on availability of access mode | No |
L2ERC20Bridge | User | Withdraw tokens from L2 to L1 | No |
L2ERC20Bridge | L1Bridge Contract | Call finalizeDeposit and mint tokens | Nil |
L2WethBridge | L1Bridge Contract | Call finalizeDeposit and deposit native ETH tokens | Nil |
L2Weth | L2Bridge Contract | Burns L2 WETH token and send ETH to the bridge | Nil |
execute()
and subsequently calls the contract itself, so the msg.sender calling the contract is the Governance contract itself.File: Governance.sol 58: modifier onlySelf() { 59: require(msg.sender == address(this), "Only governance contract itself allowed to call this function"); 60: _; 61: }
File: L2ERC20Bridge.sol 90: /// @dev Deploy and initialize the L2 token for the L1 counterpart 91: function _deployL2Token(address _l1Token, bytes calldata _data) internal returns (address) { 92: bytes32 salt = _getCreate2Salt(_l1Token); 93: 94: BeaconProxy l2Token = _deployBeaconProxy(salt); 95: L2StandardERC20(address(l2Token)).bridgeInitialize(_l1Token, _data); 96: 97: return address(l2Token); 98: }
60 hours
#0 - c4-pre-sort
2023-11-02T15:57:24Z
141345 marked the issue as sufficient quality report
#1 - c4-judge
2023-11-21T16:50:57Z
GalloDaSballo marked the issue as grade-b