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: 8/64
Findings: 3
Award: $13,656.89
π Selected for report: 1
π Solo Findings: 0
π Selected for report: minhtrng
Also found by: BARW, HE1M, Koolex, rvierdiiev
4574.0453 USDC - $4,574.05
In zksync, requestL2Transaction
can be used to send a L1->L2 TX.
function 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
This function is used by L1WethBridge.deposit
(on L1) to bridge Weth to L2WethBridge (on L2).
txHash = zkSync.requestL2Transaction{value: _amount + msg.value}( l2Bridge, _amount, l2TxCalldata, _l2TxGasLimit, _l2TxGasPerPubdataByte, new bytes[](0), refundRecipient );
L1WethBridge.deposit:L185-L193
The user should pass the required params to L1WethBridge.deposit
function deposit( address _l2Receiver, address _l1Token, uint256 _amount, uint256 _l2TxGasLimit, uint256 _l2TxGasPerPubdataByte, address _refundRecipient
L1WethBridge.deposit:L163-L164
One important param is _l2TxGasLimit
. This 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).
In other words, _l2TxGasLimit
should have an amount that's enough to cover:
To ensure that the transaction doesn't fail when executing it (on L2) via the bootloader, there is a validation enforced on requestL2Transaction
before pusing the TX to the priorityQueue.
L2CanonicalTransaction memory transaction = _serializeL2Transaction(_priorityOpParams, _calldata, _factoryDeps); bytes memory transactionEncoding = abi.encode(transaction); TransactionValidator.validateL1ToL2Transaction(transaction, transactionEncoding, s.priorityTxMaxGasLimit);
Mailbox._writePriorityOp:L361-L368
The call flow:
requestL2Transaction
=> _requestL2Transaction
=> _writePriorityOp
If we check TransactionValidator.validateL1ToL2Transaction
we see that it calls getTransactionBodyGasLimit
function validateL1ToL2Transaction( IMailbox.L2CanonicalTransaction memory _transaction, bytes memory _encoded, uint256 _priorityTxMaxGasLimit ) internal pure { uint256 l2GasForTxBody = getTransactionBodyGasLimit( _transaction.gasLimit, _transaction.gasPerPubdataByteLimit, _encoded.length );
getTransactionBodyGasLimit
function makes sure that the provided gas limit covers transaction overhead then returns the l2GasLimit for the body of the transaction (the actual execution).
function getTransactionBodyGasLimit( uint256 _totalGasLimit, uint256 _gasPricePerPubdata, uint256 _encodingLength ) internal pure returns (uint256 txBodyGasLimit) { uint256 overhead = getOverheadForTransaction(_totalGasLimit, _gasPricePerPubdata, _encodingLength); require(_totalGasLimit >= overhead, "my"); // provided gas limit doesn't cover transaction overhead unchecked { // We enforce the fact that `_totalGasLimit >= overhead` explicitly above. txBodyGasLimit = _totalGasLimit - overhead; } }
TransactionValidator:L110-L122
After that, and back to validateL1ToL2Transaction
, It ensures that the transaction covers the minimal costs for its processing (e.g. hashing its content, publishing the factory dependencies, etc).
require( getMinimalPriorityTransactionGasLimit( _encoded.length, _transaction.factoryDeps.length, _transaction.gasPerPubdataByteLimit ) <= _transaction.gasLimit, "up" );
However, the condition compares the the minimal costs with _transaction.gasLimit
which includes the overhead transaction. Due to this bug, a majority of L1->L2 TX will always fail on L2 when executing it via the bootloader. These TX will fail without even an attempt to execute it. In other words, it will not enter the following if-condition body. Therefore, never attempting to call getExecuteL1TxAndGetRefund
if gt(gasLimitForTx, gasUsedOnPreparation) { // gasLimitForTx is zero let potentialRefund := 0 potentialRefund, success := getExecuteL1TxAndGetRefund(txDataOffset, sub(gasLimitForTx, gasUsedOnPreparation)) // Asking the operator for refund askOperatorForRefund(potentialRefund) // In case the operator provided smaller refund than the one calculated // by the bootloader, we return the refund calculated by the bootloader. refundGas := max(getOperatorRefundForTx(transactionIndex), potentialRefund) }
This happens because in getGasLimitForTx
function (in bootloader), when calculating the intrinsicOverhead
, it is bigger than gasLimitForTx
(after deducting operatorOverheadForTransaction
)
gasLimitForTx := safeSub(totalGasLimit, operatorOverheadForTransaction, "qr") let intrinsicOverhead := safeAdd( intrinsicGas, // the error messages are trimmed to fit into 32 bytes safeMul(intrinsicPubdata, gasPerPubdata, "qw"), "fj" ) switch lt(gasLimitForTx, intrinsicOverhead) case 1 { gasLimitForTx := 0 } default { gasLimitForTx := sub(gasLimitForTx, intrinsicOverhead) }
In this case, gasLimitForTx
becomes zero. Since gasLimitForTx
is zero, it is smaller than gasUsedOnPreparation
. Therefore, it won't enter if-condition body seen above.
if gt(gasLimitForTx, gasUsedOnPreparation) { // gasLimitForTx is zero
As a result of this (and not limited to):
The majority of L1->L2 TXs (including deposits by L1WethBridge) will fail even though the provided L2 gas limit by the user is reasonable and already passed the validation (on L1) of minimum provided gasLimit.
Users (with failed TX) paid for operatorOverheadForTransaction
and some value less than intrinsicOverhead
for a transaction that was never attempted to be executed which is unfair for the user. Please note that, obviously this applicable only if L1->L2 transactions are not free Check the comments here.
Other various impacts which are conveyed in the report.
Please check the coded PoC below for better understanding.
We have two files:
To run the test file:
// Please change this to the relative path of provided bootloader.yul string constant bootloaderRelativePath = "solidity/test/zksync-PoCs/yul/bootloader.yul";
forge test --via-ir -vv --ffi
You should get the following output:
[PASS] test_TX_Type255_Fail() (gas: 536605312) Logs: === Validating L1ToL2Transaction === l2GasLimit provided: 380440 required overhead: 180926 remaining l2GasForTxBody: 199514 minimumL2GasForTxBodyAccepted: 243884 === Call bootloader.processTx for L1->L2 TX === Message: L1->L2 TX failed Success: false Test result: ok. 1 passed; 0 failed; finished in 2.26s
Important: please note that the PoC doesn't use any special zksync opcode since it is irrelevant to this PoC. For this reason, solc is ok to be used in this case.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; // PoC => L1->L2 TXs will fail in bootloader without even attempting to execute it import {Test} from "forge-std/Test.sol"; import "forge-std/console.sol"; import {DSTest} from "ds-test/test.sol"; uint256 constant PRIORITY_OPERATION_L2_TX_TYPE = 255; uint256 constant L2_TX_MAX_GAS_LIMIT = 80000000; uint256 constant MAX_PUBDATA_PER_BATCH = 110000; uint256 constant PRIORITY_TX_MAX_PUBDATA = 99000; uint256 constant FAIR_L2_GAS_PRICE = 500000000; uint256 constant L1_GAS_PER_PUBDATA_BYTE = 17; uint256 constant BATCH_OVERHEAD_L2_GAS = 1200000; uint256 constant BATCH_OVERHEAD_L1_GAS = 1000000; uint256 constant BATCH_OVERHEAD_PUBDATA = BATCH_OVERHEAD_L1_GAS / L1_GAS_PER_PUBDATA_BYTE; uint256 constant MAX_TRANSACTIONS_IN_BATCH = 1024; uint256 constant BOOTLOADER_TX_ENCODING_SPACE = 273132; uint256 constant L1_TX_INTRINSIC_L2_GAS = 167157; uint256 constant L1_TX_INTRINSIC_PUBDATA = 88; uint256 constant L1_TX_MIN_L2_GAS_BASE = 173484; uint256 constant L1_TX_DELTA_544_ENCODING_BYTES = 1656; uint256 constant L1_TX_DELTA_FACTORY_DEPS_L2_GAS = 2473; uint256 constant L1_TX_DELTA_FACTORY_DEPS_PUBDATA = 64; uint256 constant MAX_NEW_FACTORY_DEPS = 32; uint256 constant REQUIRED_L2_GAS_PRICE_PER_PUBDATA = 800; uint256 constant PACKED_L2_BLOCK_TIMESTAMP_MASK = 0xffffffffffffffffffffffffffffffff; interface Bootloader {} interface IL2Bridge { function finalizeDeposit( address _l1Sender, address _l2Receiver, address _l1Token, uint256 _amount, bytes calldata _data ) external payable; } interface IMailbox { struct L2CanonicalTransaction { uint256 txType; uint256 from; uint256 to; uint256 gasLimit; uint256 gasPerPubdataByteLimit; uint256 maxFeePerGas; uint256 maxPriorityFeePerGas; uint256 paymaster; uint256 nonce; uint256 value; // In the future, we might want to add some // new fields to the struct. The `txData` struct // is to be passed to account and any changes to its structure // would mean a breaking change to these accounts. To prevent this, // we should keep some fields as "reserved". // It is also recommended that their length is fixed, since // it would allow easier proof integration (in case we will need // some special circuit for preprocessing transactions). uint256[4] reserved; bytes data; bytes signature; uint256[] factoryDeps; bytes paymasterInput; // Reserved dynamic type for the future use-case. Using it should be avoided, // But it is still here, just in case we want to enable some additional functionality. bytes reservedDynamic; } } /** * copied from openzeppelin/contracts/utils/math/Math.sol */ library Math { error MathOverflowedMulDiv(); enum Rounding { Floor, // Toward negative infinity Ceil, // Toward positive infinity Trunc, // Toward zero Expand // Away from zero } function max(uint256 a, uint256 b) internal pure returns (uint256) { return a > b ? a : b; } function min(uint256 a, uint256 b) internal pure returns (uint256) { return a < b ? a : b; } function ceilDiv(uint256 a, uint256 b) internal pure returns (uint256) { if (b == 0) { return a / b; } return a == 0 ? 0 : (a - 1) / b + 1; } function mulDiv( uint256 x, uint256 y, uint256 denominator ) internal pure returns (uint256 result) { unchecked { // 512-bit multiply [prod1 prod0] = x * y. Compute the product mod 2^256 and mod 2^256 - 1, then use // use the Chinese Remainder Theorem to reconstruct the 512 bit result. The result is stored in two 256 // variables such that product = prod1 * 2^256 + prod0. uint256 prod0 = x * y; // Least significant 256 bits of the product uint256 prod1; // Most significant 256 bits of the product assembly { let mm := mulmod(x, y, not(0)) prod1 := sub(sub(mm, prod0), lt(mm, prod0)) } // Handle non-overflow cases, 256 by 256 division. if (prod1 == 0) { // Solidity will revert if denominator == 0, unlike the div opcode on its own. // The surrounding unchecked block does not change this fact. // See https://docs.soliditylang.org/en/latest/control-structures.html#checked-or-unchecked-arithmetic. return prod0 / denominator; } // Make sure the result is less than 2^256. Also prevents denominator == 0. if (denominator <= prod1) { revert MathOverflowedMulDiv(); } /////////////////////////////////////////////// // 512 by 256 division. /////////////////////////////////////////////// // Make division exact by subtracting the remainder from [prod1 prod0]. uint256 remainder; assembly { // Compute remainder using mulmod. remainder := mulmod(x, y, denominator) // Subtract 256 bit number from 512 bit number. prod1 := sub(prod1, gt(remainder, prod0)) prod0 := sub(prod0, remainder) } // Factor powers of two out of denominator and compute largest power of two divisor of denominator. // Always >= 1. See https://cs.stackexchange.com/q/138556/92363. uint256 twos = denominator & (0 - denominator); assembly { // Divide denominator by twos. denominator := div(denominator, twos) // Divide [prod1 prod0] by twos. prod0 := div(prod0, twos) // Flip twos such that it is 2^256 / twos. If twos is zero, then it becomes one. twos := add(div(sub(0, twos), twos), 1) } // Shift in bits from prod1 into prod0. prod0 |= prod1 * twos; // Invert denominator mod 2^256. Now that denominator is an odd number, it has an inverse modulo 2^256 such // that denominator * inv = 1 mod 2^256. Compute the inverse by starting with a seed that is correct for // four bits. That is, denominator * inv = 1 mod 2^4. uint256 inverse = (3 * denominator) ^ 2; // Use the Newton-Raphson iteration to improve the precision. Thanks to Hensel's lifting lemma, this also // works in modular arithmetic, doubling the correct bits in each step. inverse *= 2 - denominator * inverse; // inverse mod 2^8 inverse *= 2 - denominator * inverse; // inverse mod 2^16 inverse *= 2 - denominator * inverse; // inverse mod 2^32 inverse *= 2 - denominator * inverse; // inverse mod 2^64 inverse *= 2 - denominator * inverse; // inverse mod 2^128 inverse *= 2 - denominator * inverse; // inverse mod 2^256 // Because the division is now exact we can divide by multiplying with the modular inverse of denominator. // This will give us the correct result modulo 2^256. Since the preconditions guarantee that the outcome is // less than 2^256, this is the final result. We don't need to compute the high bits of the result and prod1 // is no longer required. result = prod0 * inverse; return result; } } function mulDiv( uint256 x, uint256 y, uint256 denominator, Rounding rounding ) internal pure returns (uint256) { uint256 result = mulDiv(x, y, denominator); if (unsignedRoundsUp(rounding) && mulmod(x, y, denominator) > 0) { result += 1; } return result; } function unsignedRoundsUp(Rounding rounding) internal pure returns (bool) { return uint8(rounding) % 2 == 1; } } library TransactionValidator { function validateL1ToL2Transaction( IMailbox.L2CanonicalTransaction memory _transaction, bytes memory _encoded, uint256 _priorityTxMaxGasLimit ) internal pure { uint256 l2GasForTxBody = getTransactionBodyGasLimit( _transaction.gasLimit, _transaction.gasPerPubdataByteLimit, _encoded.length ); require(l2GasForTxBody <= _priorityTxMaxGasLimit, "ui"); require( l2GasForTxBody / _transaction.gasPerPubdataByteLimit <= PRIORITY_TX_MAX_PUBDATA, "uk" ); require( getMinimalPriorityTransactionGasLimit( _encoded.length, _transaction.factoryDeps.length, _transaction.gasPerPubdataByteLimit ) <= _transaction.gasLimit, "up" ); } function getMinimalPriorityTransactionGasLimit( uint256 _encodingLength, uint256 _numberOfFactoryDependencies, uint256 _l2GasPricePerPubdata ) internal pure returns (uint256) { uint256 costForComputation; { // Adding the intrinsic cost for the transaction, i.e. auxiliary prices which cannot be easily accounted for costForComputation = L1_TX_INTRINSIC_L2_GAS; // 167157 // Taking into account the hashing costs that depend on the length of the transaction // Note that L1_TX_DELTA_544_ENCODING_BYTES is the delta in the price for every 544 bytes of // the transaction's encoding. It is taken as LCM between 136 and 32 (the length for each keccak256 round // and the size of each new encoding word). costForComputation += Math.ceilDiv( _encodingLength * L1_TX_DELTA_544_ENCODING_BYTES, 544 ); // Taking into the account the additional costs of providing new factory dependenies costForComputation += _numberOfFactoryDependencies * L1_TX_DELTA_FACTORY_DEPS_L2_GAS; // There is a minimal amount of computational L2 gas that the transaction should cover costForComputation = Math.max( costForComputation, L1_TX_MIN_L2_GAS_BASE ); } // L1_TX_MIN_L2_GAS_BASE => 173484 uint256 costForPubdata = 0; { // Adding the intrinsic cost for the transaction, i.e. auxilary prices which cannot be easily accounted for costForPubdata = L1_TX_INTRINSIC_PUBDATA * _l2GasPricePerPubdata; // costForPubdata => 88 * 800 => 70400 // Taking into the account the additional costs of providing new factory dependenies costForPubdata += _numberOfFactoryDependencies * L1_TX_DELTA_FACTORY_DEPS_PUBDATA * _l2GasPricePerPubdata; } // 173484 + 70400 => 243884 return costForComputation + costForPubdata; } function getTransactionBodyGasLimit( uint256 _totalGasLimit, uint256 _gasPricePerPubdata, uint256 _encodingLength ) internal pure returns (uint256 txBodyGasLimit) { uint256 overhead = getOverheadForTransaction( _totalGasLimit, _gasPricePerPubdata, _encodingLength ); require(_totalGasLimit >= overhead, "my"); // provided gas limit doesn't cover transaction overhead unchecked { // We enforce the fact that `_totalGasLimit >= overhead` explicitly above. txBodyGasLimit = _totalGasLimit - overhead; } } function getOverheadForTransaction( uint256 _totalGasLimit, uint256 _gasPricePerPubdata, uint256 _encodingLength ) internal pure returns (uint256 batchOverheadForTransaction) { uint256 batchOverheadGas = BATCH_OVERHEAD_L2_GAS + BATCH_OVERHEAD_PUBDATA * _gasPricePerPubdata; // The overhead from taking up the transaction's slot 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 ); // The overhead for possible published public data // TODO: possibly charge a separate fee for possible pubdata spending // uint256 overheadForPublicData; // { // uint256 numerator = (batchOverheadGas * _totalGasLimit + _gasPricePerPubdata * MAX_PUBDATA_PER_BATCH); // uint256 denominator = (_gasPricePerPubdata * MAX_PUBDATA_PER_BATCH + batchOverheadGas); // overheadForPublicData = (numerator - 1) / denominator; // } // batchOverheadForTransaction = Math.max(batchOverheadForTransaction, overheadForPublicData); // The overhead for ergs that could be used to use single-instance circuits uint256 overheadForGas; { uint256 numerator = batchOverheadGas * _totalGasLimit + L2_TX_MAX_GAS_LIMIT; uint256 denominator = L2_TX_MAX_GAS_LIMIT + batchOverheadGas; overheadForGas = (numerator - 1) / denominator; } batchOverheadForTransaction = Math.max( batchOverheadForTransaction, overheadForGas ); } } // slightly modified => inspired by https://github.com/CodeForcer/foundry-yul/blob/main/test/lib/YulDeployer.sol contract YulDeployer is Test { function deployContract( string memory fileNameRelativePath ) public returns (address) { string memory bashCommand = string.concat( 'cast abi-encode "f(bytes)" $(solc --strict-assembly --optimize --optimize-runs 200 ', string.concat(fileNameRelativePath, " --bin | tail -1)") ); string[] memory inputs = new string[](3); inputs[0] = "bash"; inputs[1] = "-c"; inputs[2] = bashCommand; bytes memory bytecode = abi.decode(vm.ffi(inputs), (bytes)); address deployedAddress; assembly { deployedAddress := create(0, add(bytecode, 0x20), mload(bytecode)) } require( deployedAddress != address(0), "YulDeployer could not deploy contract" ); return deployedAddress; } } // Please change this to the relative path of provided bootloader.yul string constant bootloaderRelativePath = "solidity/test/zksync-PoCs/yul/bootloader.yul"; contract BootloaderMockTest is DSTest, Test { YulDeployer yulDeployer = new YulDeployer(); Bootloader bootloaderMock; address OPERATOR_ADDRESS = vm.addr(1); address FROM_ADDRESS = vm.addr(2); address TO_ADDRESS = vm.addr(3); address PAYMASTER_ADDRESS = vm.addr(4); address REFUND_ADDRESS = vm.addr(5); bytes l2TxCalldata; uint256[4] reserved; uint256 gasPerPubdataByteLimit = 800; // check: https://github.com/matter-labs/zksync-era/blob/73a1e8ff564025d06e02c2689da238ae47bb10c3/sdk/zksync-web3.js/src/utils.ts#L61 function setUp() public { vm.deal(OPERATOR_ADDRESS, 100 ether); { bootloaderMock = Bootloader( yulDeployer.deployContract(bootloaderRelativePath) ); } } function _getTXDataEncoded( IMailbox.L2CanonicalTransaction memory transaction ) internal pure returns (bytes memory txCalldataEncoded) { txCalldataEncoded = abi.encode( transaction.txType, transaction.from, transaction.to, transaction.gasLimit, transaction.gasPerPubdataByteLimit, transaction.maxFeePerGas, transaction.maxPriorityFeePerGas, transaction.paymaster, transaction.nonce, transaction.value, transaction.reserved, transaction.data, transaction.signature, transaction.factoryDeps, transaction.paymasterInput, transaction.reservedDynamic ); } function _getDepositL2Calldata( address _l1Sender, address _l2Receiver, address _l1Token, uint256 _amount ) internal pure returns (bytes memory txCalldata) { txCalldata = abi.encodeCall( IL2Bridge.finalizeDeposit, (_l1Sender, _l2Receiver, _l1Token, _amount, new bytes(0)) ); } function test_TX_Type255_Fail() public { // stack too deep. sorry { l2TxCalldata = _getDepositL2Calldata( address(this), vm.addr(11), vm.addr(12), 1 ether ); } { reserved[0] = 3 ether; // reserved0 has ether to be minted reserved[1] = uint256(uint160(bytes20(REFUND_ADDRESS))); // the address that would receive the refund // check: https://github.com/code-423n4/2023-10-zksync/blob/main/docs/Smart%20contract%20Section/System%20contracts%20bootloader%20description.md#l1-l2-transactions } IMailbox.L2CanonicalTransaction memory transaction; { transaction.txType = PRIORITY_OPERATION_L2_TX_TYPE; transaction.from = uint256(uint160(bytes20(FROM_ADDRESS))); transaction.to = uint256(uint160(bytes20(TO_ADDRESS))); transaction.gasLimit = uint256(380_440); transaction.gasPerPubdataByteLimit = gasPerPubdataByteLimit; transaction.maxFeePerGas = uint256(13e9); transaction.maxPriorityFeePerGas = uint256(0); transaction.paymaster = uint256(0); transaction.nonce = uint256(1); transaction.value = uint256(1 ether); transaction.reserved = reserved; transaction.data = l2TxCalldata; transaction.signature = new bytes(0); transaction.paymasterInput = new bytes(0); transaction.reservedDynamic = new bytes(0); transaction.factoryDeps = new uint256[](0); } // Transaction should pass validation on L1 bytes memory transactionEncoding = abi.encode(transaction); console.log("=== Validating L1ToL2Transaction ==="); TransactionValidator.validateL1ToL2Transaction( transaction, transactionEncoding, (2 ** 32) - 1 ); uint256 overhead = TransactionValidator.getOverheadForTransaction( transaction.gasLimit, transaction.gasPerPubdataByteLimit, transactionEncoding.length ); uint256 l2GasForTxBody = TransactionValidator .getTransactionBodyGasLimit( transaction.gasLimit, transaction.gasPerPubdataByteLimit, transactionEncoding.length ); uint256 minimumL2GasForTxBodyAccepted = TransactionValidator .getMinimalPriorityTransactionGasLimit( transactionEncoding.length, transaction.factoryDeps.length, transaction.gasPerPubdataByteLimit ); console.log("l2GasLimit provided: %d", transaction.gasLimit); console.log("required overhead: %d", overhead); console.log("remaining l2GasForTxBody: %d", l2GasForTxBody); console.log( "minimumL2GasForTxBodyAccepted: %d", minimumL2GasForTxBodyAccepted ); // Pass data to bootloader to pprocess L1->L2 tx vm.startPrank(OPERATOR_ADDRESS); bytes memory txCalldataEncoded = _getTXDataEncoded(transaction); (bool status, bytes memory ret) = address(bootloaderMock).call( txCalldataEncoded ); // Use this in case you need to print values (by reverting) from bootloader // console.log("=== ret ==="); // console.logBytes(ret); // console.log(uint256(bytes32(ret))); console.log("=== Call bootloader.processTx for L1->L2 TX ==="); console.log("Message: ", string(ret)); console.log("Success: ", status); assertEq(status, false); } }
object "Bootloader" { code { datacopy(0, dataoffset("runtime"), datasize("runtime")) return(0, datasize("runtime")) } object "runtime" { code { function MAX_ALLOWED_L1_GAS_PRICE() -> ret { // 100k gwei ret := 100000000000000 } function MAX_ALLOWED_FAIR_L2_GAS_PRICE() -> ret { // 10k gwei ret := 10000000000000 } function validateOperatorProvidedPrices(l1GasPrice, fairL2GasPrice) { if gt(l1GasPrice, MAX_ALLOWED_L1_GAS_PRICE()) { assertionError("L1 gas price too high") } if gt(fairL2GasPrice, MAX_ALLOWED_FAIR_L2_GAS_PRICE()) { assertionError("L2 fair gas price too high") } } function getBaseFee(l1GasPrice, fairL2GasPrice) -> baseFee, gasPricePerPubdata { // By default, we want to provide the fair L2 gas price. // That it means that the operator controls // what the value of the baseFee will be. In the future, // a better system, aided by EIP1559 should be added. let pubdataBytePriceETH := safeMul(l1GasPrice, L1_GAS_PER_PUBDATA_BYTE(), "aoa") baseFee := max( fairL2GasPrice, ceilDiv(pubdataBytePriceETH, MAX_L2_GAS_PER_PUBDATA()) ) gasPricePerPubdata := ceilDiv(pubdataBytePriceETH, baseFee) } function GUARANTEED_PUBDATA_PER_TX() -> ret { ret := 4000 } function MAX_L2_GAS_PER_PUBDATA() -> ret { ret := div(MAX_GAS_PER_TRANSACTION(), GUARANTEED_PUBDATA_PER_TX()) } function BATCH_OVERHEAD_L2_GAS() -> ret { ret := 1200000 } function BATCH_OVERHEAD_L1_GAS() -> ret { ret := 1000000 } function MAX_GAS_PER_TRANSACTION() -> ret { ret := 80000000 } function L1_GAS_PER_PUBDATA_BYTE() -> ret { ret := 17 } function BOOTLOADER_MEMORY_FOR_TXS() -> ret { ret := 273132 } function MAX_TRANSACTIONS_IN_BATCH() -> ret { ret := 1024 } function SCRATCH_SPACE_BEGIN_SLOT() -> ret { ret := 8 } function SCRATCH_SPACE_SLOTS() -> ret { ret := 32 } function PAYMASTER_CONTEXT_SLOTS() -> ret { ret := 33 } function PAYMASTER_CONTEXT_BEGIN_SLOT() -> ret { ret := add(SCRATCH_SPACE_BEGIN_SLOT(), SCRATCH_SPACE_SLOTS()) } function MAX_POSTOP_SLOTS() -> ret { ret := add(PAYMASTER_CONTEXT_SLOTS(), 7) } function CURRENT_L2_TX_HASHES_RESERVED_SLOTS() -> ret { ret := 2 } function CURRENT_L2_TX_HASHES_BEGIN_SLOT() -> ret { ret := add(PAYMASTER_CONTEXT_BEGIN_SLOT(), PAYMASTER_CONTEXT_SLOTS()) } function MAX_NEW_FACTORY_DEPS() -> ret { ret := 32 } function NEW_FACTORY_DEPS_RESERVED_SLOTS() -> ret { ret := add(MAX_NEW_FACTORY_DEPS(), 4) } function NEW_FACTORY_DEPS_BEGIN_SLOT() -> ret { ret := add(CURRENT_L2_TX_HASHES_BEGIN_SLOT(), CURRENT_L2_TX_HASHES_RESERVED_SLOTS()) } function TX_OPERATOR_REFUND_BEGIN_SLOT() -> ret { ret := add(NEW_FACTORY_DEPS_BEGIN_SLOT(), NEW_FACTORY_DEPS_RESERVED_SLOTS()) } function TX_OPERATOR_REFUND_BEGIN_BYTE() -> ret { ret := mul(TX_OPERATOR_REFUND_BEGIN_SLOT(), 32) } function TX_OPERATOR_REFUNDS_SLOTS() -> ret { ret := MAX_TRANSACTIONS_IN_BATCH() } function TX_SUGGESTED_OVERHEAD_BEGIN_SLOT() -> ret { ret := add(TX_OPERATOR_REFUND_BEGIN_SLOT(), TX_OPERATOR_REFUNDS_SLOTS()) } function TX_SUGGESTED_OVERHEAD_BEGIN_BYTE() -> ret { ret := mul(TX_SUGGESTED_OVERHEAD_BEGIN_SLOT(), 32) } function TX_SUGGESTED_OVERHEAD_SLOTS() -> ret { ret := MAX_TRANSACTIONS_IN_BATCH() } function TX_OPERATOR_TRUSTED_GAS_LIMIT_BEGIN_SLOT() -> ret { ret := add(TX_SUGGESTED_OVERHEAD_BEGIN_SLOT(), TX_SUGGESTED_OVERHEAD_SLOTS()) } function TX_OPERATOR_TRUSTED_GAS_LIMIT_BEGIN_BYTE() -> ret { ret := mul(TX_OPERATOR_TRUSTED_GAS_LIMIT_BEGIN_SLOT(), 32) } function TX_OPERATOR_TRUSTED_GAS_LIMIT_SLOTS() -> ret { ret := MAX_TRANSACTIONS_IN_BATCH() } function TX_OPERATOR_L2_BLOCK_INFO_BEGIN_SLOT() -> ret { ret := add(TX_OPERATOR_TRUSTED_GAS_LIMIT_BEGIN_SLOT(), TX_OPERATOR_TRUSTED_GAS_LIMIT_SLOTS()) } function TX_OPERATOR_L2_BLOCK_INFO_SLOT_SIZE() -> ret { ret := 4 } function TX_OPERATOR_L2_BLOCK_INFO_SLOTS() -> ret { ret := mul(add(MAX_TRANSACTIONS_IN_BATCH(), 1), TX_OPERATOR_L2_BLOCK_INFO_SLOT_SIZE()) } function COMPRESSED_BYTECODES_BEGIN_SLOT() -> ret { ret := add(TX_OPERATOR_L2_BLOCK_INFO_BEGIN_SLOT(), TX_OPERATOR_L2_BLOCK_INFO_SLOTS()) } function COMPRESSED_BYTECODES_BEGIN_BYTE() -> ret { ret := mul(COMPRESSED_BYTECODES_BEGIN_SLOT(), 32) } function COMPRESSED_BYTECODES_SLOTS() -> ret { ret := 32768 } function PRIORITY_TXS_L1_DATA_RESERVED_SLOTS() -> ret { ret := 2 } function PRIORITY_TXS_L1_DATA_BEGIN_SLOT() -> ret { ret := add(COMPRESSED_BYTECODES_BEGIN_SLOT(), COMPRESSED_BYTECODES_SLOTS()) } function PRIORITY_TXS_L1_DATA_BEGIN_BYTE() -> ret { ret := mul(PRIORITY_TXS_L1_DATA_BEGIN_SLOT(), 32) } function OPERATOR_PROVIDED_L1_MESSENGER_PUBDATA_BEGIN_SLOT() -> ret { ret := add(PRIORITY_TXS_L1_DATA_BEGIN_SLOT(), PRIORITY_TXS_L1_DATA_RESERVED_SLOTS()) } function OPERATOR_PROVIDED_L1_MESSENGER_PUBDATA_BEGIN_BYTE() -> ret { ret := mul(OPERATOR_PROVIDED_L1_MESSENGER_PUBDATA_BEGIN_SLOT(), 32) } function OPERATOR_PROVIDED_L1_MESSENGER_PUBDATA_SLOTS() -> ret { ret := 208000 } function OPERATOR_PROVIDED_L1_MESSENGER_PUBDATA_END_SLOT() -> ret { ret := add(OPERATOR_PROVIDED_L1_MESSENGER_PUBDATA_BEGIN_SLOT(), OPERATOR_PROVIDED_L1_MESSENGER_PUBDATA_SLOTS()) } function TX_DESCRIPTION_BEGIN_SLOT() -> ret { ret := OPERATOR_PROVIDED_L1_MESSENGER_PUBDATA_END_SLOT() } function TX_DESCRIPTION_BEGIN_BYTE() -> ret { ret := mul(TX_DESCRIPTION_BEGIN_SLOT(), 32) } function TX_DESCRIPTION_SIZE() -> ret { ret := 64 } function TXS_IN_BATCH_LAST_PTR() -> ret { ret := add(TX_DESCRIPTION_BEGIN_BYTE(), mul(MAX_TRANSACTIONS_IN_BATCH(), TX_DESCRIPTION_SIZE())) } function MAX_MEM_SIZE() -> ret { ret := 0x1000000 } function L1_TX_INTRINSIC_L2_GAS() -> ret { ret := 167157 } function L1_TX_INTRINSIC_PUBDATA() -> ret { ret := 88 } function L2_TX_INTRINSIC_GAS() -> ret { ret := 14070 } function L2_TX_INTRINSIC_PUBDATA() -> ret { ret := 0 } function RESULT_START_PTR() -> ret { ret := sub(MAX_MEM_SIZE(), mul(MAX_TRANSACTIONS_IN_BATCH(), 32)) } function VM_HOOK_PTR() -> ret { ret := sub(RESULT_START_PTR(), 32) } function VM_HOOK_PARAMS() -> ret { ret := 2 } function VM_HOOK_PARAMS_OFFSET() -> ret { ret := sub(VM_HOOK_PTR(), mul(VM_HOOK_PARAMS(), 32)) } function LAST_FREE_SLOT() -> ret { ret := sub(VM_HOOK_PARAMS_OFFSET(), 32) } function BOOTLOADER_FORMAL_ADDR() -> ret { ret := 0x0000000000000000000000000000000000008001 } function MAX_SYSTEM_CONTRACT_ADDR() -> ret { ret := 0x000000000000000000000000000000000000ffff } function CONTRACT_DEPLOYER_ADDR() -> ret { ret := 0x0000000000000000000000000000000000008006 } function MSG_VALUE_SIMULATOR_ADDR() -> ret { ret := 0x0000000000000000000000000000000000008009 } function ceilDiv(x, y) -> ret { switch or(eq(x, 0), eq(y, 0)) case 0 { ret := add(div(sub(x, 1), y), 1) } default { ret := 0 } } function lengthRoundedByWords(len) -> ret { let neededWords := div(add(len, 31), 32) ret := safeMul(neededWords, 32, "xv") } function processTx( txDataOffset, resultPtr, transactionIndex, isETHCall, gasPerPubdata ) { setL2Block(transactionIndex) let innerTxDataOffset := add(txDataOffset, 32) // By default we assume that the transaction has failed. mstore(resultPtr, 0) let userProvidedPubdataPrice := getGasPerPubdataByteLimit(innerTxDataOffset) switch getTxType(innerTxDataOffset) case 255 { processL1Tx(txDataOffset, resultPtr, transactionIndex, userProvidedPubdataPrice, true) } default { } } function getCanonicalL1TxHash(txDataOffset) -> ret { mstore(txDataOffset, 32) let innerTxDataOffset := add(txDataOffset, 32) let dataLength := safeAdd(32, getDataLength(innerTxDataOffset), "qev") ret := keccak256(txDataOffset, dataLength) } function mintEther(to, amount, useNearCallPanic) { } function processL1Tx( txDataOffset, resultPtr, transactionIndex, gasPerPubdata, isPriorityOp ) { setPricePerPubdataByte(gasPerPubdata) // Skipping the first formal 0x20 byte let innerTxDataOffset := add(txDataOffset, 32) let gasLimitForTx, reservedGas := getGasLimitForTx( innerTxDataOffset, transactionIndex, gasPerPubdata, L1_TX_INTRINSIC_L2_GAS(), L1_TX_INTRINSIC_PUBDATA() ) // @audit: gasLimitForTx => 0 if intrinsicOverhead is bigger // than gasLimitForTx after deducting OperatorOverhead which causes the issue let gasUsedOnPreparation := 0 let canonicalL1TxHash := 0 canonicalL1TxHash, gasUsedOnPreparation := l1TxPreparation(txDataOffset) let refundGas := 0 let success := 0 let gasLimit := getGasLimit(innerTxDataOffset) // totalGasLimit let 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") } if gt(gasLimitForTx, gasUsedOnPreparation) { // gasLimitForTx could be zero let potentialRefund := 0 success := 1 // set tx to success as we don't need to actually execute it for the PoC as it won't enter here // potentialRefund, success := getExecuteL1TxAndGetRefund(txDataOffset, sub(gasLimitForTx, gasUsedOnPreparation)) // Asking the operator for refund // askOperatorForRefund(potentialRefund) // In case the operator provided smaller refund than the one calculated // by the bootloader, we return the refund calculated by the bootloader. // refundGas := max(getOperatorRefundForTx(transactionIndex), potentialRefund) } // @audit: if we reach here, and success is still zero. // This means we didn't enter the if-condition body above refundGas := add(refundGas, reservedGas) if gt(refundGas, gasLimit) { assertionError("L1: refundGas > gasLimit") } let payToOperator := safeMul(gasPrice, safeSub(gasLimit, refundGas, "lpah"), "mnk") // notifyAboutRefund(refundGas) // commented out not important // Paying the fee to the operator // body of this function is commented out but it is irrelavant here mintEther(BOOTLOADER_FORMAL_ADDR(), payToOperator, false) let toRefundRecipient switch success case 0 { toRefundRecipient := safeSub(getReserved0(innerTxDataOffset), payToOperator, "vji") } default { toRefundRecipient := safeSub(getReserved0(innerTxDataOffset), safeAdd(getValue(innerTxDataOffset), payToOperator, "kpa"), "ysl") } if gt(toRefundRecipient, 0) { let refundRecipient := getReserved1(innerTxDataOffset) refundRecipient := and(refundRecipient, sub(shl(160, 1), 1)) mintEther(refundRecipient, toRefundRecipient, false) // body of this function is commented out but it is irrelavant here } mstore(resultPtr, success) sendL2LogUsingL1Messenger(true, canonicalL1TxHash, success) if isPriorityOp { mstore(0, mload(PRIORITY_TXS_L1_DATA_BEGIN_BYTE())) mstore(32, canonicalL1TxHash) mstore(PRIORITY_TXS_L1_DATA_BEGIN_BYTE(), keccak256(0, 64)) mstore(add(PRIORITY_TXS_L1_DATA_BEGIN_BYTE(), 32), add(mload(add(PRIORITY_TXS_L1_DATA_BEGIN_BYTE(), 32)), 1)) } } function getExecuteL1TxAndGetRefund(txDataOffset, gasForExecution) -> potentialRefund, success { // body removed as it is not required for this PoC } function l1TxPreparation(txDataOffset) -> canonicalL1TxHash, gasUsedOnPreparation { let innerTxDataOffset := add(txDataOffset, 32) let gasBeforePreparation := gas() canonicalL1TxHash := getCanonicalL1TxHash(txDataOffset) appendTransactionHash(canonicalL1TxHash, true) markFactoryDepsForTx(innerTxDataOffset, true) gasUsedOnPreparation := safeSub(gasBeforePreparation, gas(), "xpa") } function getGasPrice( maxFeePerGas, maxPriorityFeePerGas ) -> ret { let baseFee := basefee() if gt(maxPriorityFeePerGas, maxFeePerGas) { revertWithReason( MAX_PRIORITY_FEE_PER_GAS_GREATER_THAN_MAX_FEE_PER_GAS(), 0 ) } if gt(baseFee, maxFeePerGas) { revertWithReason( BASE_FEE_GREATER_THAN_MAX_FEE_PER_GAS(), 0 ) } ret := baseFee } function getGasLimitForTx( innerTxDataOffset, transactionIndex, gasPerPubdata, intrinsicGas, intrinsicPubdata ) -> gasLimitForTx, reservedGas { let totalGasLimit := getGasLimit(innerTxDataOffset) let operatorTrustedGasLimit := max(MAX_GAS_PER_TRANSACTION(), getOperatorTrustedGasLimitForTx(transactionIndex)) // We remember the amount of gas that is beyond the operator's trust limit to refund it back later. switch gt(totalGasLimit, operatorTrustedGasLimit) case 0 { reservedGas := 0 } default { reservedGas := sub(totalGasLimit, operatorTrustedGasLimit) totalGasLimit := operatorTrustedGasLimit } let txEncodingLen := safeAdd(32, getDataLength(innerTxDataOffset), "lsh") let operatorOverheadForTransaction := getVerifiedOperatorOverheadForTx( transactionIndex, totalGasLimit, gasPerPubdata, txEncodingLen ) // operatorOverheadForTransaction => 180926 gasLimitForTx := safeSub(totalGasLimit, operatorOverheadForTransaction, "qr") let intrinsicOverhead := safeAdd( intrinsicGas, // the error messages are trimmed to fit into 32 bytes safeMul(intrinsicPubdata, gasPerPubdata, "qw"), "fj" ) // intrinsicOverhead => 237557 // gasLimitForTx => 199514 which is not enough switch lt(gasLimitForTx, intrinsicOverhead) case 1 { gasLimitForTx := 0 } default { gasLimitForTx := sub(gasLimitForTx, intrinsicOverhead) } } function getOperatorRefundForTx(transactionIndex) -> ret { let refundPtr := add(TX_OPERATOR_REFUND_BEGIN_BYTE(), mul(transactionIndex, 32)) ret := mload(refundPtr) } function getOperatorOverheadForTx(transactionIndex) -> ret { let txBatchOverheadPtr := add(TX_SUGGESTED_OVERHEAD_BEGIN_BYTE(), mul(transactionIndex, 32)) ret := mload(txBatchOverheadPtr) } function getOperatorTrustedGasLimitForTx(transactionIndex) -> ret { let txTrustedGasLimitPtr := add(TX_OPERATOR_TRUSTED_GAS_LIMIT_BEGIN_BYTE(), mul(transactionIndex, 32)) ret := mload(txTrustedGasLimitPtr) } function getVerifiedOperatorOverheadForTx( transactionIndex, txTotalGasLimit, gasPerPubdataByte, txEncodeLen ) -> ret { let operatorOverheadForTransaction := getOperatorOverheadForTx(transactionIndex) if gt(operatorOverheadForTransaction, txTotalGasLimit) { assertionError("Overhead higher than gasLimit") } let txGasLimit := min(safeSub(txTotalGasLimit, operatorOverheadForTransaction, "www"), MAX_GAS_PER_TRANSACTION()) let requiredOverhead := getTransactionUpfrontOverhead( txGasLimit, gasPerPubdataByte, txEncodeLen ) // The required overhead is less than the overhead that the operator // has requested from the user, meaning that the operator tried to overcharge the user if lt(requiredOverhead, operatorOverheadForTransaction) { assertionError("Operator's overhead too high") } ret := operatorOverheadForTransaction } /// Returns the batch overhead to be paid, assuming a certain value of gasPerPubdata function getBatchOverheadGas(gasPerPubdata) -> ret { let computationOverhead := BATCH_OVERHEAD_L2_GAS() let l1GasOverhead := BATCH_OVERHEAD_L1_GAS() let l1GasPerPubdata := L1_GAS_PER_PUBDATA_BYTE() let pubdataEquivalentForL1Gas := safeDiv(l1GasOverhead, l1GasPerPubdata, "dd") ret := safeAdd( computationOverhead, safeMul(gasPerPubdata, pubdataEquivalentForL1Gas, "aa"), "ab" ) } function getTransactionUpfrontOverhead( txGasLimit, gasPerPubdataByte, txEncodeLen ) -> ret { ret := 0 let totalBatchOverhead := getBatchOverheadGas(gasPerPubdataByte) let overheadForCircuits := ceilDiv( safeMul(totalBatchOverhead, txGasLimit, "ac"), MAX_GAS_PER_TRANSACTION() // 80_000_000 ) ret := max(ret, overheadForCircuits) let overheadForLength := ceilDiv( safeMul(txEncodeLen, totalBatchOverhead, "ad"), BOOTLOADER_MEMORY_FOR_TXS() ) ret := max(ret, overheadForLength) let overheadForSlot := ceilDiv( totalBatchOverhead, MAX_TRANSACTIONS_IN_BATCH() ) ret := max(ret, overheadForSlot) } function max(x, y) -> ret { ret := y if gt(x, y) { ret := x } } function min(x, y) -> ret { ret := y if lt(x, y) { ret := x } } function EMPTY_STRING_KECCAK() -> ret { ret := 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470 } function lte(x, y) -> ret { ret := or(lt(x,y), eq(x,y)) } function markFactoryDepsForTx(innerTxDataOffset, isL1Tx) { } /// /// zkSync-specific utilities: /// function sendL2LogUsingL1Messenger(isService, key, value) { } function setPricePerPubdataByte(newPrice) { // not needed here } function setL2Block(txId) { } function appendTransactionHash( txHash, isL1Tx ) { } /// @notice Asserts the equality of two values and reverts /// with the appropriate error message in case it doesn't hold function assertEq(value1, value2, message) { switch eq(value1, value2) case 0 { assertionError(message) } default { } } /// @notice Makes sure that the structure of the transaction is set in accordance to its type function validateTypedTxStructure(innerTxDataOffset) { /// Some common checks for all transactions. let reservedDynamicLength := getReservedDynamicBytesLength(innerTxDataOffset) if gt(reservedDynamicLength, 0) { assertionError("non-empty reservedDynamic") } let txType := getTxType(innerTxDataOffset) switch txType // only 255 is required for this PoC case 255 { // Double-check that the operator doesn't try to do an upgrade transaction via L1 -> L2 transaction. assertEq(gt(getFrom(innerTxDataOffset), MAX_SYSTEM_CONTRACT_ADDR()), 1, "from in kernel space") // L1 transaction, no need to validate as it is validated on L1. } default { assertionError("Unknown tx type") } } /// /// TransactionData utilities /// /// @dev The next methods are programmatically generated /// function getTxType(innerTxDataOffset) -> ret { ret := mload(innerTxDataOffset) } function getFrom(innerTxDataOffset) -> ret { ret := mload(add(innerTxDataOffset, 32)) } function getTo(innerTxDataOffset) -> ret { ret := mload(add(innerTxDataOffset, 64)) } function getGasLimit(innerTxDataOffset) -> ret { ret := mload(add(innerTxDataOffset, 96)) } function getGasPerPubdataByteLimit(innerTxDataOffset) -> ret { ret := mload(add(innerTxDataOffset, 128)) } function getMaxFeePerGas(innerTxDataOffset) -> ret { ret := mload(add(innerTxDataOffset, 160)) } function getMaxPriorityFeePerGas(innerTxDataOffset) -> ret { ret := mload(add(innerTxDataOffset, 192)) } function getPaymaster(innerTxDataOffset) -> ret { ret := mload(add(innerTxDataOffset, 224)) } function getNonce(innerTxDataOffset) -> ret { ret := mload(add(innerTxDataOffset, 256)) } function getValue(innerTxDataOffset) -> ret { ret := mload(add(innerTxDataOffset, 288)) } function getReserved0(innerTxDataOffset) -> ret { ret := mload(add(innerTxDataOffset, 320)) } function getReserved1(innerTxDataOffset) -> ret { ret := mload(add(innerTxDataOffset, 352)) } function getReserved2(innerTxDataOffset) -> ret { ret := mload(add(innerTxDataOffset, 384)) } function getReserved3(innerTxDataOffset) -> ret { ret := mload(add(innerTxDataOffset, 416)) } function getDataPtr(innerTxDataOffset) -> ret { ret := mload(add(innerTxDataOffset, 448)) ret := add(innerTxDataOffset, ret) } function getDataBytesLength(innerTxDataOffset) -> ret { let ptr := getDataPtr(innerTxDataOffset) ret := lengthRoundedByWords(mload(ptr)) } function getSignaturePtr(innerTxDataOffset) -> ret { ret := mload(add(innerTxDataOffset, 480)) ret := add(innerTxDataOffset, ret) } function getSignatureBytesLength(innerTxDataOffset) -> ret { let ptr := getSignaturePtr(innerTxDataOffset) ret := lengthRoundedByWords(mload(ptr)) } function getFactoryDepsPtr(innerTxDataOffset) -> ret { ret := mload(add(innerTxDataOffset, 512)) ret := add(innerTxDataOffset, ret) } function getFactoryDepsBytesLength(innerTxDataOffset) -> ret { let ptr := getFactoryDepsPtr(innerTxDataOffset) ret := safeMul(mload(ptr),32, "fwop") } function getPaymasterInputPtr(innerTxDataOffset) -> ret { ret := mload(add(innerTxDataOffset, 544)) ret := add(innerTxDataOffset, ret) } function getPaymasterInputBytesLength(innerTxDataOffset) -> ret { let ptr := getPaymasterInputPtr(innerTxDataOffset) ret := lengthRoundedByWords(mload(ptr)) } function getReservedDynamicPtr(innerTxDataOffset) -> ret { ret := mload(add(innerTxDataOffset, 576)) ret := add(innerTxDataOffset, ret) } function getReservedDynamicBytesLength(innerTxDataOffset) -> ret { let ptr := getReservedDynamicPtr(innerTxDataOffset) ret := lengthRoundedByWords(mload(ptr)) } /// This method checks that the transaction's structure is correct /// and tightly packed function validateAbiEncoding(txDataOffset) -> ret { if iszero(eq(mload(txDataOffset), 32)) { assertionError("Encoding offset") } let innerTxDataOffset := add(txDataOffset, 32) let fromValue := getFrom(innerTxDataOffset) if iszero(validateAddress(fromValue)) { assertionError("Encoding from") } let toValue := getTo(innerTxDataOffset) if iszero(validateAddress(toValue)) { assertionError("Encoding to") } let gasLimitValue := getGasLimit(innerTxDataOffset) if iszero(validateUint32(gasLimitValue)) { assertionError("Encoding gasLimit") } let gasPerPubdataByteLimitValue := getGasPerPubdataByteLimit(innerTxDataOffset) if iszero(validateUint32(gasPerPubdataByteLimitValue)) { assertionError("Encoding gasPerPubdataByteLimit") } let maxFeePerGas := getMaxFeePerGas(innerTxDataOffset) if iszero(validateUint128(maxFeePerGas)) { assertionError("Encoding maxFeePerGas") } let maxPriorityFeePerGas := getMaxPriorityFeePerGas(innerTxDataOffset) if iszero(validateUint128(maxPriorityFeePerGas)) { assertionError("Encoding maxPriorityFeePerGas") } let paymasterValue := getPaymaster(innerTxDataOffset) if iszero(validateAddress(paymasterValue)) { assertionError("Encoding paymaster") } let expectedDynamicLenPtr := add(innerTxDataOffset, 608) let dataLengthPos := getDataPtr(innerTxDataOffset) // let ptr := 0 // mstore(ptr, dataLengthPos) // ptr := add(ptr, 32) // revert(0, ptr) if iszero(eq(dataLengthPos, expectedDynamicLenPtr)) { assertionError("Encoding data") } expectedDynamicLenPtr := validateBytes(dataLengthPos) let signatureLengthPos := getSignaturePtr(innerTxDataOffset) if iszero(eq(signatureLengthPos, expectedDynamicLenPtr)) { assertionError("Encoding signature") } expectedDynamicLenPtr := validateBytes(signatureLengthPos) let factoryDepsLengthPos := getFactoryDepsPtr(innerTxDataOffset) if iszero(eq(factoryDepsLengthPos, expectedDynamicLenPtr)) { assertionError("Encoding factoryDeps") } expectedDynamicLenPtr := validateBytes32Array(factoryDepsLengthPos) let paymasterInputLengthPos := getPaymasterInputPtr(innerTxDataOffset) if iszero(eq(paymasterInputLengthPos, expectedDynamicLenPtr)) { assertionError("Encoding paymasterInput") } expectedDynamicLenPtr := validateBytes(paymasterInputLengthPos) let reservedDynamicLengthPos := getReservedDynamicPtr(innerTxDataOffset) if iszero(eq(reservedDynamicLengthPos, expectedDynamicLenPtr)) { assertionError("Encoding reservedDynamic") } expectedDynamicLenPtr := validateBytes(reservedDynamicLengthPos) ret := expectedDynamicLenPtr } function getDataLength(innerTxDataOffset) -> ret { // To get the length of the txData in bytes, we can simply // get the number of fields * 32 + the length of the dynamic types // in bytes. ret := 768 ret := safeAdd(ret, getDataBytesLength(innerTxDataOffset), "asx") ret := safeAdd(ret, getSignatureBytesLength(innerTxDataOffset), "qwqa") ret := safeAdd(ret, getFactoryDepsBytesLength(innerTxDataOffset), "sic") ret := safeAdd(ret, getPaymasterInputBytesLength(innerTxDataOffset), "tpiw") ret := safeAdd(ret, getReservedDynamicBytesLength(innerTxDataOffset), "shy") } /// /// End of programmatically generated code /// /// @dev Accepts an address and returns whether or not it is /// a valid address function validateAddress(addr) -> ret { ret := lt(addr, shl(160, 1)) } /// @dev Accepts an uint32 and returns whether or not it is /// a valid uint32 function validateUint32(x) -> ret { ret := lt(x, shl(32,1)) } /// @dev Accepts an uint32 and returns whether or not it is /// a valid uint64 function validateUint128(x) -> ret { ret := lt(x, shl(128,1)) } /// Validates that the `bytes` is formed correctly /// and returns the pointer right after the end of the bytes function validateBytes(bytesPtr) -> bytesEnd { let length := mload(bytesPtr) let lastWordBytes := mod(length, 32) switch lastWordBytes case 0 { // If the length is divisible by 32, then // the bytes occupy whole words, so there is // nothing to validate bytesEnd := safeAdd(bytesPtr, safeAdd(length, 32, "pol"), "aop") } default { // If the length is not divisible by 32, then // the last word is padded with zeroes, i.e. // the last 32 - `lastWordBytes` bytes must be zeroes // The easiest way to check this is to use AND operator let zeroBytes := sub(32, lastWordBytes) // It has its first 32 - `lastWordBytes` bytes set to 255 let mask := sub(shl(mul(zeroBytes,8),1),1) let fullLen := lengthRoundedByWords(length) bytesEnd := safeAdd(bytesPtr, safeAdd(32, fullLen, "dza"), "dzp") let lastWord := mload(sub(bytesEnd, 32)) // If last word contains some unintended bits // return 0 if and(lastWord, mask) { assertionError("bad bytes encoding") } } } /// @dev Accepts the pointer to the bytes32[] array length and /// returns the pointer right after the array's content function validateBytes32Array(arrayPtr) -> arrayEnd { // The bytes32[] array takes full words which may contain any content. // Thus, there is nothing to validate. let length := mload(arrayPtr) arrayEnd := safeAdd(arrayPtr, safeAdd(32, safeMul(length, 32, "lop"), "asa"), "sp") } /// /// Safe math utilities /// function safeMul(x, y, errMsg) -> ret { switch y case 0 { ret := 0 } default { ret := mul(x, y) if iszero(eq(div(ret, y), x)) { assertionError(errMsg) } } } function safeDiv(x, y, errMsg) -> ret { if iszero(y) { assertionError(errMsg) } ret := div(x, y) } function safeAdd(x, y, errMsg) -> ret { ret := add(x, y) if lt(ret, x) { assertionError(errMsg) } } function safeSub(x, y, errMsg) -> ret { if gt(y, x) { assertionError(errMsg) } ret := sub(x, y) } /// /// Debug utilities /// function debugReturndata() { } function notifyAboutRefund(refund) { } function askOperatorForRefund(gasLeft) { } /// /// Error codes used for more correct diagnostics from the server side. /// function MAX_PRIORITY_FEE_PER_GAS_GREATER_THAN_MAX_FEE_PER_GAS() -> ret { ret := 13 } function BASE_FEE_GREATER_THAN_MAX_FEE_PER_GAS() -> ret { ret := 14 } function ASSERTION_ERROR() -> ret { ret := 17 } /// @dev Accepts a 1-word literal and returns its length in bytes function getStrLen(str) -> len { len := 0 for {} str {str := shl(8, str)} { len := add(len, 1) } } function GENERAL_ERROR_SELECTOR() -> ret { } function assertionError(err) { let ptr := 0 mstore8(ptr, ASSERTION_ERROR()) ptr := add(ptr, 1) mstore(ptr, GENERAL_ERROR_SELECTOR()) ptr := add(ptr, 4) mstore(ptr, 32) ptr := add(ptr, 32) mstore(ptr, getStrLen(err)) ptr := add(ptr, 32) mstore(ptr, err) ptr := add(ptr, 32) revert(0, ptr) } function revertWithReason(errCode, sendReturnData) { let returndataLen := 1 mstore8(0, errCode) if sendReturnData { returndataLen := add(returndataLen, returndatasize()) returndatacopy(1, 0, returndatasize()) } revert(0, returndataLen) } //////////////////////////////////////////////////////////////////////////// // For PoC , call processTX directly to process one L1->L2 TX only // //////////////////////////////////////////////////////////////////////////// let L1_GAS_PRICE := 13000000000 // Assume it as 13 Gwei // mload(128) let FAIR_L2_GAS_PRICE := 500000000 // mload(160) let baseFee, GAS_PRICE_PER_PUBDATA := getBaseFee(L1_GAS_PRICE, FAIR_L2_GAS_PRICE) let gasPerPubdata := 0 let currentExpectedTxOffset := add(TXS_IN_BATCH_LAST_PTR(), mul(MAX_POSTOP_SLOTS(), 32)) let txPtr := TX_DESCRIPTION_BEGIN_BYTE() mstore(COMPRESSED_BYTECODES_BEGIN_BYTE(), add(COMPRESSED_BYTECODES_BEGIN_BYTE(), 32)) mstore(PRIORITY_TXS_L1_DATA_BEGIN_BYTE(), EMPTY_STRING_KECCAK()) mstore(add(PRIORITY_TXS_L1_DATA_BEGIN_BYTE(), 32), 0) let transactionIndex := 0 let resultPtr := RESULT_START_PTR() txPtr := add(txPtr, TX_DESCRIPTION_SIZE()) resultPtr := add(resultPtr, 32) transactionIndex := add(transactionIndex, 1) let txDataOffset := mload(add(txPtr, 32)) txDataOffset := currentExpectedTxOffset // set txDataOffset to the expected // Set Encoding offset mstore(txDataOffset, 32) // innerTxDataOffset Starts from txType let innerTxDataOffset := add(txDataOffset, 32) // copy from calldata calldatacopy(innerTxDataOffset, 0, calldatasize()) currentExpectedTxOffset := validateAbiEncoding(txDataOffset) // Checking whether the last slot of the transaction's description // does not go out of bounds. if gt(sub(currentExpectedTxOffset, 32), LAST_FREE_SLOT()) { assertionError("currentExpectedTxOffset too high") } validateTypedTxStructure(add(txDataOffset, 32)) // The operator sets the trusted gas limit let txTrustedGasLimitPtr := add(TX_OPERATOR_TRUSTED_GAS_LIMIT_BEGIN_BYTE(), mul(transactionIndex, 32)) mstore(txTrustedGasLimitPtr,80000000) // The operator sets the OperatorOverheadForTx let txBatchOverheadPtr := add(TX_SUGGESTED_OVERHEAD_BEGIN_BYTE(), mul(transactionIndex, 32)) mstore(txBatchOverheadPtr,180926) // requiredOverhead => getTransactionUpfrontOverhead // 180926 is the maximum requiredOverhead that the operator can provide. Otherwise, it reverts processTx(txDataOffset, resultPtr, transactionIndex, 0, GAS_PRICE_PER_PUBDATA) let success := mload(resultPtr) // Check if the Tx succeeded. if iszero(success){ assertionError("L1->L2 TX failed") } } } }
Manual analysis
getMinimalPriorityTransactionGasLimit( _encoded.length, _transaction.factoryDeps.length, _transaction.gasPerPubdataByteLimit ) <= l2GasForTxBody, "up"
TransactionValidator.sol#L36-L43
Invalid Validation
#0 - c4-pre-sort
2023-11-01T09:47:26Z
bytes032 marked the issue as primary issue
#1 - c4-pre-sort
2023-11-02T16:22:23Z
141345 marked the issue as sufficient quality report
#2 - miladpiri
2023-11-06T12:38:48Z
While the describe is large & complex, it is about wrong validation of transactions have enough gas. Severity is Medium.
Explanation:
getMinimalPriorityTransactionGasLimit
calculates how much gas the priority tx will minimally consume (intrinsic costs + some other costs like hashing it, etc). However, _transaction.gasLimit
besides those costs also contains the overhead (for batch verification, etc) costs. Basically this means that if getMinimalPriorityTransactionGasLimit = _transaction.gasLimit
, then the _transaction.gasLimit once it pays for the overhead may pay little-to-nothing for the processing of this transaction (it will be marked as failed, but the operator will have to bear the costs anyway as he must process, hash L1 transactions etc).
#3 - c4-sponsor
2023-11-06T12:38:57Z
miladpiri (sponsor) confirmed
#4 - c4-sponsor
2023-11-06T12:39:01Z
miladpiri marked the issue as disagree with severity
#5 - c4-judge
2023-11-28T12:43:43Z
GalloDaSballo changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-11-28T12:45:45Z
GalloDaSballo changed the severity to 3 (High Risk)
#7 - GalloDaSballo
2023-11-28T12:46:09Z
I need to look into this further, but if it's valid then it should be High as the math for validation is != math for execution
#8 - c4-judge
2023-11-28T15:27:09Z
GalloDaSballo changed the severity to 2 (Med Risk)
#9 - GalloDaSballo
2023-11-28T15:29:13Z
From the Zksync side, their job is not to ensure that the tx will not run OOG, as that cannot be guaranteed
Their job is to ensure that the "cost of processing" is lower than the "cost paid"
I believe the check meets those requirements, in that, zkSync is defending itself against spam
This however, seem to create scenarios where a normal user could have a tx validated as functioning which would end up reverting on L2
My new interpretation of the issue leans between QA for gotcha and Med, and I'm leaning towards Med rn mostly due to the Sponsor opinion that the math can be improved
#10 - c4-judge
2023-11-28T16:09:42Z
GalloDaSballo marked the issue as selected for report
#11 - Minh-Trng
2023-12-02T15:23:54Z
From the Zksync side, their job is not to ensure that the tx will not run OOG, as that cannot be guaranteed
Their job is to ensure that the "cost of processing" is lower than the "cost paid"
This is the actual issue here, it is indeed possible to create a L1->L2 tx that will not cover the full "cost of processing", as explained by sponsor above:
Basically this means that if getMinimalPriorityTransactionGasLimit = _transaction.gasLimit, then the _transaction.gasLimit once it pays for the overhead may pay little-to-nothing for the processing of this transaction (it will be marked as failed, but the operator will have to bear the costs anyway as he must process, hash L1 transactions etc).
I apologize for sounding a bit self-righteous here, but the only reports from the dups that identify and explain this impact correctly are #1108 (mine) and #66 (subtly mentioning it at the end).
#975, #179 and #476 have correctly identified the root cause, but miss the actual impact. Especially this one (#975) is very bloated with unnecessary code (just copied almost the whole bootloader here) and describes a completely wrong impact ("majority of L1-L2 txs failing"), so I would like to advocate for any of the other submissions getting the "selected for report tag"
thank you for your time and dilligence
#12 - koolexcrypto
2023-12-04T16:30:05Z
Hi @GalloDaSballo
Their job is to ensure that the "cost of processing" is lower than the "cost paid"
This is not met as the user could pay for less than intrinsicOverhead
. PoC is runnable.
Please search for this in the PoC
// intrinsicOverhead => 237557 // gasLimitForTx => 199514 which is not enough
Use this to print out values from bootloader
let ptr := 0 mstore(ptr, gasLimitForTx) // or mstore(ptr, intrinsicOverhead) ptr := add(ptr, 32) revert(0, ptr)
#13 - c4-judge
2023-12-07T10:16:34Z
GalloDaSballo marked issue #1108 as primary and marked this issue as a duplicate of 1108
#14 - GalloDaSballo
2023-12-07T10:17:39Z
I agree with the change of best report due to clarity and conciseness, however, I believe the duplicates meet the criteria for full duplicate, including this one
#15 - c4-judge
2023-12-07T10:17:45Z
GalloDaSballo marked the issue as satisfactory
π Selected for report: Koolex
Also found by: Audittens, rvierdiiev
8809.2724 USDC - $8,809.27
In zksync, requestL2Transaction
can be used to send a L1->L2 TX.
function 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:
To ensure this, there is a validation enforced on requestL2Transaction
before pusing the TX to the priorityQueue.
L2CanonicalTransaction 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.
When the bootloader receives the L1->L2 TX, it does the following:
let 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).
txInternalCost
and value
let 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") }
gasUsedOnPreparation
then calls getExecuteL1TxAndGetRefund
to attempt to execute the TX. This function returns a potentialRefund if there is any.if 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.
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.
let gasBeforeExecution := gas() success := ZKSYNC_NEAR_CALL_executeL1Tx( callAbi, txDataOffset )
// 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.
/// @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.
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:
To run the test file:
forge test --via-ir -vv
You should get the following output:
[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
// 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); } }
Manual analysis
One suggestion is to use invalid opcode instead of burning gas.
Other
#0 - c4-pre-sort
2023-11-02T13:47:17Z
141345 marked the issue as sufficient quality report
#1 - miladpiri
2023-11-06T12:42:09Z
It is Low. The user does indeed lose all the gas if success is false, as we invoke the nearCallPanic
that burns all the gas in the near call (which is basically all the exeuction gas).
#2 - c4-sponsor
2023-11-06T12:42:17Z
miladpiri marked the issue as disagree with severity
#3 - c4-sponsor
2023-11-06T12:42:24Z
miladpiri (sponsor) confirmed
#4 - c4-judge
2023-11-26T12:55:22Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#5 - GalloDaSballo
2023-11-26T12:55:59Z
I believe the design decision makes sense, a L1->L2 must consume all gas as otherwise computing the refunds would be ripe for exploitation
I think gotcha makes sense
#6 - koolexcrypto
2023-12-03T14:58:13Z
@GalloDaSballo Thank you for your huge efforts put in for this big contest.
I kindly ask you to re-evaluate the issue above considering the following:
If you check the differences from Ethereum at zkSync docs, EVM REVERT instruction is not listed. meaning that, REVERT behaviour in zkSync Era VM should behave exactly the same as in EVM. Here is a simple summary of how the EIP-140 defines the REVERT instruction
The REVERT instruction provides a way to stop execution and revert state changes, without consuming all provided gas and with the ability to return a reason
The REVERT instruction behaviour is also confirmed in the ZkSync Era Virtual Machine primer document which says
Revert: a recoverable error happened. Unspent gas is returned to the caller, which will execute the exception handler. The instruction is revert.
However, it is not the case as demonstrated in the issue above. This is due to the fact that when a TX (requested from L1) fails due to a REVERT opcode in the contract code of the callee, all the remaining gas (from the actual execution gas) will be always consumed. This behaviour is similiar to the deprecated/removed opcode throw
from EVM.
Assume we have:
Let's check two scenarios:
First => User X calls Function F by sending an L2 TX directly, at line 4, the execution finishes due to the revert and all unspent gas is returned to the caller.
Second => User X calls the same function Function F by sending an L2 TX indirectly (i.e. requested from L1), at line 4, the execution stops due to the revert and all unspent gas is consumed anyway.
In the first scenario, REVERT behaves exactly as expected.
In the second scenario, REVERT behaves as if it was the deprecated/removed opcode throw
.
Please note that we are talking here about the actual execution gas.
From this, we can see how the Bootloader breaks the zkSync Era VM consistency
Some distinctive feature of zkSync Era's fee model is the abundance of refunds for unused limited system resources and overpaid computation. Please check High-level: conclusion which says
The other distinctive feature of the fee model used on zkSync is the abundance of refunds, i.e.:
- For unused limited system resources.
- For overpaid computation.
Also, In the Fee mechanism docs which says:
zkSync Era's fee model is similar to Ethereumβs where gas is charged for computational cost, cost of publishing data on-chain and storage effects.
The above issue shows how the implementation is not aligned with this design since it charges for opcodes that were never executed (i.e. opcodes that happen to be after a REVERT). Therfore, no resources have been used for it.
In the light of this basis, the unspent gas should be returned to the user especially when the user can not influence the relevant logic in the bootloader by any mean. In other words, we have 4 actor:
When the caller sends an L2 TX (from L1), It seems that the caller can not provide any input that could prevent the issue from occuring.
Lastly, it breaks the trust assumption that the users (or protocols) have, since they assume that they will receive back their unspent gas from the actual execution gas since it wasn't consumed by the TX.
I appreciate you taking the time to read my comment. I'm looking forward to hearing your feedback on this.
#7 - c4-judge
2023-12-10T18:37:23Z
This previously downgraded issue has been upgraded by GalloDaSballo
#8 - c4-judge
2023-12-10T18:37:23Z
This previously downgraded issue has been upgraded by GalloDaSballo
#9 - c4-judge
2023-12-10T18:37:35Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#10 - c4-judge
2023-12-10T18:39:26Z
This previously downgraded issue has been upgraded by GalloDaSballo
#11 - c4-judge
2023-12-10T18:39:26Z
This previously downgraded issue has been upgraded by GalloDaSballo
#12 - c4-judge
2023-12-10T18:39:38Z
GalloDaSballo marked the issue as selected for report
#13 - c4-judge
2023-12-10T18:39:43Z
GalloDaSballo marked the issue as primary issue
#14 - GalloDaSballo
2023-12-10T18:43:15Z
I went back and forth on this finding a bit because:
Due to this, the finding can be interpreted as saying that: L1->L2 txs that revert will consume all gas
Due to this being inconsistent for the EVM, and potentially risky for end users, I agree with Medium Severity
#15 - GalloDaSballo
2024-01-11T15:34:56Z
I was asked to double check this finding
This my reasoning for maintaining Medium Severity <img width="465" alt="Screenshot 2024-01-11 at 16 33 40" src="https://github.com/code-423n4/2023-10-zksync-findings/assets/13383782/e9dfafe7-18c5-48fb-b2ae-1ac6e19a72d2">
The gas used is known and a refund could be computed, which is imo inconsistent with other behaviours of the system
I understand the Sponsor may elect for a nofix, and that this would be a warning to developers / end-users / integrators
π 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
The user will be unable to claim his failed deposit on L1 when the deposit limitation is active after being inactive initially. Thus, the deposited amount is stuck on L1 causing a loss of funds for the user.
An example, a user deposited 100 USDC but failed on L2. The 100 USDC is still locked on L1. When the user tries to claim them from the L1 using claimFailedDeposit
, the function will revert.
claimFailedDeposit
will revert on _verifyDepositLimit
function.First, let's check the deposit function. It calls _verifyDepositLimit
to verify the deposit limit.
function deposit( address _l2Receiver, address _l1Token, uint256 _amount, uint256 _l2TxGasLimit, uint256 _l2TxGasPerPubdataByte, address _refundRecipient ) public payable nonReentrant senderCanCallFunction(allowList) returns (bytes32 l2TxHash) { require(_amount != 0, "2T"); // empty deposit amount uint256 amount = _depositFunds(msg.sender, IERC20(_l1Token), _amount); require(amount == _amount, "1T"); // The token has non-standard transfer logic // verify the deposit amount is allowed _verifyDepositLimit(_l1Token, msg.sender, _amount, false);
Assuming limitData.depositLimitation
is not active (false) then it will return at Line:342 and the transaction will continue with no issues. However, no amount will be added to the depositor in _verifyDepositLimit
function. because totalDepositedAmountPerUser[_l1Token][_depositor] += _amount;
wasn't reached since limitData.depositLimitation
was off.
The owner of AllowList contract for some reason activated limitData.depositLimitation
and now it is true.
Now when the deposit transaction failed, the user called claimFailedDeposit
to claim the deposited amount
function claimFailedDeposit( address _depositSender, address _l1Token, bytes32 _l2TxHash, uint256 _l2BatchNumber, uint256 _l2MessageIndex, uint16 _l2TxNumberInBatch, bytes32[] calldata _merkleProof ) external nonReentrant senderCanCallFunction(allowList) { bool proofValid = zkSync.proveL1ToL2TransactionStatus( _l2TxHash, _l2BatchNumber, _l2MessageIndex, _l2TxNumberInBatch, _merkleProof, TxStatus.Failure ); require(proofValid, "yn"); uint256 amount = depositAmount[_depositSender][_l1Token][_l2TxHash]; require(amount > 0, "y1"); // Change the total deposited amount by the user _verifyDepositLimit(_l1Token, _depositSender, amount, true);
Since _verifyDepositLimit
is called to change the total deposited amount of the user and limitData.depositLimitation
is active, the function will revert. This is because there is no amount of the depositor Initially and it will try to deduct the _amount
from totalDepositedAmountPerUser[_l1Token][_depositor]
which has zero value. Thus, leading to a revert.
function _verifyDepositLimit(address _l1Token, address _depositor, uint256 _amount, bool _claiming) internal { IAllowList.Deposit memory limitData = IAllowList(allowList).getTokenDepositLimitData(_l1Token); 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"); totalDepositedAmountPerUser[_l1Token][_depositor] += _amount; } }
Here is where the amount deduction is attempted causing the revert.
totalDepositedAmountPerUser[_l1Token][_depositor] -= _amount;
As you can imagine, various issue could emerge from activating and deactivating the deposit limitation openly.
Please note that when the issue above occurs, the funds are stuck and the only way to allow funds to be claimed is, deactivating the deposit limitation for the effected ERC20 token. because of this, if the issue occurs, we will always have at least one impact from the two:
For this reason, I've set the severity to high.
Manual Review
One suggestion is, to always increase the amount of deposited amount regardless if the deposit limitation is active. Only disable decreasing the amount when the deposit limitation is active.
Other
#0 - c4-pre-sort
2023-11-02T04:35:56Z
bytes032 marked the issue as primary issue
#1 - c4-pre-sort
2023-11-02T13:44:19Z
141345 marked the issue as duplicate of #425
#2 - c4-judge
2023-11-24T19:55:33Z
GalloDaSballo changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-11-24T20:01:18Z
GalloDaSballo marked the issue as satisfactory