Loss of funds for the sender when L1->L2 TX fails in the bootloader on L2
mediumLines of code
Vulnerability details
Impact
Sending L1->L2 TX
In zksync, requestL2Transaction can be used to send a L1->L2 TX.
solidityfunction requestL2Transaction( address _contractL2, uint256 _l2Value, bytes calldata _calldata, uint256 _l2GasLimit, uint256 _l2GasPerPubdataByteLimit, bytes[] calldata _factoryDeps, address _refundRecipient ) external payable nonReentrant senderCanCallFunction(s.allowList) returns (bytes32 canonicalTxHash) {
Mailbox.requestL2Transaction:L236-L245
An important param is _l2TxGasLimit which is used to process the TX on L2. The L2 gas limit should include both the overhead for processing the batch and the L2 gas needed to process the transaction itself (i.e. the actual l2GasLimit that will be used for the transaction).
So, _l2TxGasLimit should have an amount that's enough to cover:
- Transaction overhead
- The transaction itself (body execution)
To ensure this, there is a validation enforced on requestL2Transaction before pusing the TX to the priorityQueue.
solidityL2CanonicalTransaction memory transaction = _serializeL2Transaction(_priorityOpParams, _calldata, _factoryDeps); bytes memory transactionEncoding = abi.encode(transaction); TransactionValidator.validateL1ToL2Transaction(transaction, transactionEncoding, s.priorityTxMaxGasLimit);
Mailbox._writePriorityOp:L361-L368
This is to make sure the transaction at least get a chance to be executed.
Bootloader L1->L2 TX execuation flow
When the bootloader receives the L1->L2 TX, it does the following:
- Calculates the L2 gas limit for the transaction's body, i.e. without intrinsic costs and overhead.
soliditylet gasLimitForTx, reservedGas := getGasLimitForTx( innerTxDataOffset, transactionIndex, gasPerPubdata, L1_TX_INTRINSIC_L2_GAS(), L1_TX_INTRINSIC_PUBDATA() )
gasLimitForTx => is the gas limit for the transaction's body.
reservedGas => is the amount of gas that is beyond the operator's trust limit to refund it back later.
Note: the operator's trust limit is guaranteed to be at least MAX_GAS_PER_TRANSACTION which is at the moment 80000000 (check SystemConfig.json).
- Ensure that the deposited eth covers
txInternalCostandvalue
soliditylet gasPrice := getMaxFeePerGas(innerTxDataOffset) let txInternalCost := safeMul(gasPrice, gasLimit, "poa") let value := getValue(innerTxDataOffset) if lt(getReserved0(innerTxDataOffset), safeAdd(value, txInternalCost, "ol")) { assertionError("deposited eth too low") }
- Checks if gasLimitForTx is greater than
gasUsedOnPreparationthen callsgetExecuteL1TxAndGetRefundto attempt to execute the TX. This function returns a potentialRefund if there is any.
solidityif gt(gasLimitForTx, gasUsedOnPreparation) { let potentialRefund := 0 potentialRefund, success := getExecuteL1TxAndGetRefund(txDataOffset, sub(gasLimitForTx, gasUsedOnPreparation))
For example, let's say the sender has 10_000_000 gas limit (after deducting the overhead ..etc.), and the TX execution consumed 3_000_000, then the potentialRefund is supposed to be 10_000_000-3_000_000 = 7_000_000. This will be returned to refundRecipient. However, if the actual TX execution fails, then potentialRefund will always be zero. Therefore, no refund for the sender at all. In other words, let's say that the TX execution consumed only 500_000 only till it reverted (for what ever reason). so, the potentialRefund should be 9_500_000 which is not the case since it will be always zero on failure.
Note: obviously this is not applicable if L1->L2 transactions were set to be free Check the comments here.
Reason & Explanation
This issue occurs due to the fact that near call opcode is used to execute the TX (to avoid 63/64 rule), and when the TX execution fails, near call panic is utilised to avoid reverting the bootloader and to revert minting ether to the user.
soliditylet gasBeforeExecution := gas() success := ZKSYNC_NEAR_CALL_executeL1Tx( callAbi, txDataOffset )
solidity// If the success is zero, we will revert in order // to revert the minting of ether to the user if iszero(success) { nearCallPanic() }
Check zkSync specific opcodes: Generally accessible,
near_call. It is basically a โframedโ jump to some location of the code of your contract. The difference between the near_call and ordinary jump are: It is possible to provide an ergsLimit for it. Note, that unlike โfar_callโs (i.e. calls between contracts) the 63/64 rule does not apply to them. If the near call frame panics, all state changes made by it are reversed. Please note, that the memory changes will not be reverted.
Please note that the only way to revert only the near_call frame (and not the parent) is to trigger out of gas error or invalid opcode.
Check Simulations via our compiler: Simulating near_call (in Yul only)
Important note: the compiler behaves in a way that if there is a revert in the bootloader, the ZKSYNC_CATCH_NEAR_CALL is not called and the parent frame is reverted as well. The only way to revert only the near_call frame is to trigger VMโs panic (it can be triggered with either invalid opcode or out of gas error).
In ZKSYNC_NEAR_CALL_executeL1Tx, nearCallPanic() is called in case of TX failure. If we check nearCallPanic() function, we find that it exhausts all the gas of the current frame so that out of gas error is triggered.
solidity/// @dev Used to panic from the nearCall without reverting the parent frame. /// If you use `revert(...)`, the error will bubble up from the near call and /// make the bootloader to revert as well. This method allows to exit the nearCall only. function nearCallPanic() { // Here we exhaust all the gas of the current frame. // This will cause the execution to panic. // Note, that it will cause only the inner call to panic. precompileCall(gas()) }
Because of this, no matter how much gas was spent on the TX itself, if it fails, all the unused remaining gas will be burned.
According to the docs zkSync: Batch overhead & limited resources of the batch the refund should be provided at the end of the trasnaction.
Note, that before the transaction is executed, the system can not know how many of the limited system resources the transaction will actually take, so we need to charge for the worst case and provide the refund at the end of the transaction
On the surface, this might not look like a critical issue since the lost funds are relatively small. However, this may be true for normal or small transactions unlike computationally intensive tasks which may require big upfront payment. The lost funds will be not negligible.
Please refer to How baseFee works on zkSync
This does not actually matter a lot for normal transactions, since most of the costs will still go on pubdata for them. However, it may matter for computationally intensive tasks, meaning that for them a big upfront payment will be required, with the refund at the end of the transaction for all the overspent gas.
Please note that while there is MAX_TRANSACTION_GAS_LIMIT for the gasLimit, it may go way beyond the MAX_TRANSACTION_GAS_LIMIT (since the contracts can be 10s of kilobytes in size). This is called Trusted gas limit which is provided by the operator.
Please check Trusted gas limit
the operator may provide the trusted gas limit, i.e. the limit which exceeds MAX_TRANSACTION_GAS_LIMIT assuming that the operator knows what he is doing (e.g. he is sure that the excess gas will be spent on the pubdata).
From this, we conclude that the upper limit for the loss is the cost of the gaslimit provided by the user which could possibly be a big amount of payment to cover complex tasks (assuming the operator provided a trusted gas limit bigger or equal to gaslimit provided by the user).
It's worth mentioning, that this breaks the trust assumptions that the users have, since the users assume that they will always get a refund if it wasn't consumed by their requested TX.
For the reasons explained above, I've set the severity to high.
Proof of Concept
-
We have one test file. This file demonstrates two case:
test_actual_gas_spent_on_success()=> When the TX succeed, the potential refund holds the actual remaining gas to be refunded.test_no_gas_refund_on_failure=> When the TX fails, the potential refund is zero.
-
Important notes:
- While the test is not executed via zkEVM, the only specific opcode for this PoC is, the near call panic. For this reason, it is simulated.
- The code is written in solidity for simplicity. However, for this PoC, it is good enough to demonstrate and prove the issue.
-
To run the test file:
shforge test --via-ir -vv -
You should get the following output:
sh[PASS] test_actual_gas_spent_on_success() (gas: 47121) Logs: Nearcall callAbi: 100000000 gasSpentOnExecution: 31533 success: true potentialRefund: 99968467 [PASS] test_no_gas_refund_on_failure() (gas: 101606767) Logs: Nearcall callAbi: 100000000 gasSpentOnExecution: 101591221 success: false potentialRefund: 0 Test result: ok. 2 passed; 0 failed; finished in 604.85ms
Test File
solidity// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; // PoC => No refund for gas on L1->L2 tx failure, it always burns the gas even if not used import {Test} from "forge-std/Test.sol"; import "forge-std/console.sol"; import {DSTest} from "ds-test/test.sol"; uint256 constant OVERHEAD_TX = 100_000; // assume overhead as 100000 uint256 constant GAS_PREP = 2000; // assume preparation value contract ExternalContract { uint256 varState; function doSomething(uint256 num) external { varState = 1; // revert if num is zero to cause nearCallPanic later if (num == 0) { revert("something wrong happened"); } } } interface IExternalContract { function doSomething(uint256 num) external; } interface IBooloaderMock { function ZKSYNC_NEAR_CALL_SIMULATION_executeL1Tx( uint256 callAbi, bytes memory txCalldataEncoded ) external; } contract BooloaderMock { ExternalContract externalContract; constructor() { externalContract = new ExternalContract(); } /// @dev The overhead in gas that will be used when checking whether the context has enough gas, i.e. /// when checking for X gas, the context should have at least X+CHECK_ENOUGH_GAS_OVERHEAD() gas. function CHECK_ENOUGH_GAS_OVERHEAD() internal pure returns (uint256 ret) { ret = 1000000; } function checkEnoughGas(uint256 gasToProvide) internal view { // Using margin of CHECK_ENOUGH_GAS_OVERHEAD gas to make sure that the operation will indeed // have enough gas // CHECK_ENOUGH_GAS_OVERHEAD => 1_000_000 if (gasleft() < (gasToProvide + CHECK_ENOUGH_GAS_OVERHEAD())) { revert("No enough gas"); } } function notifyExecutionResult(bool success) internal {} function nearCallPanic() internal pure { // Here we exhaust all the gas of the current frame. // This will cause the execution to panic. // Note, that it will cause only the inner call to panic. uint256 x = 0; while (true) { x += 1; } } // simulation of near call function ZKSYNC_NEAR_CALL_SIMULATION_executeL1Tx( uint256 callAbi, bytes memory txCalldataEncoded ) public { (bool success, ) = address(externalContract).call{gas: callAbi}( txCalldataEncoded ); if (!success) { // nearCall panic nearCallPanic(); } } function getExecuteL1TxAndGetRefund( uint256 gasForExecution, bytes memory txCalldataExternalContract ) internal returns (uint256 potentialRefund, bool success) { uint256 callAbi = gasForExecution; checkEnoughGas(gasForExecution); uint256 gasBeforeExecution = gasleft(); bytes memory txCalldataEncoded = abi.encodeCall( IBooloaderMock.ZKSYNC_NEAR_CALL_SIMULATION_executeL1Tx, (callAbi, txCalldataExternalContract) ); console.log("Nearcall callAbi: %d", callAbi); // pass 64/63 to simulate nearCall that doesn't follow this 63/64 rule uint256 fullGas = (callAbi * 64) / 63; (success, ) = address(this).call{gas: fullGas}(txCalldataEncoded); notifyExecutionResult(success); uint256 gasSpentOnExecution = gasBeforeExecution - gasleft(); console.log("gasSpentOnExecution: %d", gasSpentOnExecution); if (gasSpentOnExecution <= gasForExecution) { potentialRefund = gasForExecution - gasSpentOnExecution; } } function processL1Tx( uint256 l2ValueProvidedByUser, uint256 gasLimitProvidedByUser, bytes memory txCalldataExternalContract ) external payable returns (uint256 potentialRefund, bool success) { uint256 overheadTX = OVERHEAD_TX; // assume overhead for simplicity uint256 gasLimitForTx = gasLimitProvidedByUser - overheadTX; uint256 gasUsedOnPreparation = GAS_PREP; // assume preparation value simplicity uint256 gasLimit = gasLimitProvidedByUser; uint256 gasPrice = 13e9; uint256 txInternalCost = gasPrice * gasLimit; require( msg.value >= l2ValueProvidedByUser + txInternalCost, "deposited eth too low" ); require(gasLimitForTx > gasUsedOnPreparation, "Tx didn't continue"); (potentialRefund, success) = getExecuteL1TxAndGetRefund( (gasLimitForTx - gasUsedOnPreparation), txCalldataExternalContract ); } } contract BootloaderMockTest is DSTest, Test { BooloaderMock bootloaderMock; function setUp() public { bootloaderMock = new BooloaderMock(); vm.deal(address(this),100 ether); } function test_no_gas_refund_on_failure() public { uint256 gasLimitByUser = 100_000_000 + OVERHEAD_TX + GAS_PREP; uint256 l2Value = 0; bytes memory txCalldataExternalContract = abi.encodeCall( IExternalContract.doSomething, (0) // value 0 cause the call to fail ); (uint256 potentialRefund, bool success) = bootloaderMock.processL1Tx{ value: 10 ether }(l2Value, gasLimitByUser, txCalldataExternalContract); console.log("success: ", success); console.log("potentialRefund: %d", potentialRefund); } function test_actual_gas_spent_on_success() public { uint256 gasLimitByUser = 100_000_000 + OVERHEAD_TX + GAS_PREP; uint256 l2Value = 0; bytes memory txCalldataExternalContract = abi.encodeCall( IExternalContract.doSomething, (1) // value 1 makes the call successful ); (uint256 potentialRefund, bool success) = bootloaderMock.processL1Tx{ value: 10 ether }(l2Value, gasLimitByUser, txCalldataExternalContract); console.log("success: ", success); console.log("potentialRefund: %d", potentialRefund); } }
Tools Used
Manual analysis
Recommended Mitigation Steps
One suggestion is to use invalid opcode instead of burning gas.
Assessed type
Other
