From 681bbcf4da46f8b7c5a1fa5d91efff94239dfe34 Mon Sep 17 00:00:00 2001 From: Rodi-Can Bozman Date: Tue, 2 Sep 2025 15:49:30 +0200 Subject: [PATCH 1/4] Etherlink: complete validation for initial tx gas consumption limit --- etherlink/bin_node/lib_dev/prevalidator.ml | 172 +++++++++++++++++--- etherlink/bin_node/lib_dev/prevalidator.mli | 5 +- 2 files changed, 153 insertions(+), 24 deletions(-) diff --git a/etherlink/bin_node/lib_dev/prevalidator.ml b/etherlink/bin_node/lib_dev/prevalidator.ml index 94276e5bf28e..2c6cccceea42 100644 --- a/etherlink/bin_node/lib_dev/prevalidator.ml +++ b/etherlink/bin_node/lib_dev/prevalidator.ml @@ -2,12 +2,15 @@ (* *) (* SPDX-License-Identifier: MIT *) (* Copyright (c) 2025 Nomadic Labs *) +(* Copyright (c) 2025 Functori *) (* *) (*****************************************************************************) open Ethereum_types -type error += Gas_limit_too_low of string | Prague_not_enabled +type error += + | Gas_limit_too_low of {gas_limit : Z.t; minimum_gas_limit_required : Z.t} + | Prague_not_enabled let () = register_error_kind @@ -16,14 +19,26 @@ let () = ~title:"Gas limit too low" ~description: "Transaction with a gas limit below the set threshold is not allowed" - ~pp:(fun ppf gas_limit -> + ~pp:(fun ppf (gas_limit, minimum_gas_limit_required) -> Format.fprintf ppf - "Transaction with a gas limit below %s is not allowed" - gas_limit) - Data_encoding.(obj1 (req "gas_limit" string)) - (function Gas_limit_too_low gas_limit -> Some gas_limit | _ -> None) - (fun gas_limit -> Gas_limit_too_low gas_limit) ; + "The provided gas limit (%a) is insufficient to cover the transaction \ + cost of %a gas. Please increase the gas limit or use eth_estimateGas \ + to get the recommended amount." + Z.pp_print + gas_limit + Z.pp_print + minimum_gas_limit_required) + Data_encoding.( + obj2 + (req "gas_limit" Data_encoding.z) + (req "minimum_gas_limit_required" Data_encoding.z)) + (function + | Gas_limit_too_low {gas_limit; minimum_gas_limit_required} -> + Some (gas_limit, minimum_gas_limit_required) + | _ -> None) + (fun (gas_limit, minimum_gas_limit_required) -> + Gas_limit_too_low {gas_limit; minimum_gas_limit_required}) ; register_error_kind `Permanent ~id:"evm_node_prague_not_enabled" @@ -35,6 +50,37 @@ let () = (function Prague_not_enabled -> Some () | _ -> None) (fun () -> Prague_not_enabled) +module K = struct + (* Constants extracted from several EIPs. + For more details see: + - https://eips.ethereum.org/EIPS/eip-7623 + - https://eips.ethereum.org/EIPS/eip-3860 *) + + let base_intrisic_gas_cost = 21_000 + + let nonzero_bytes_cost = 16 + + let standard_token_cost = 4 + + let non_zero_byte_multiplier = nonzero_bytes_cost / standard_token_cost + + let total_cost_floor_per_token = 10 + + let base_creation_cost = 32_000 + + let initcode_word_cost = 2 + + let word_size = 32 + + let access_list_address = 2_400 + + let access_list_storage_key = 1_900 + + let eip7702_empty_account_cost = 25_000 +end + +let is_prague_enabled ~storage_version = storage_version >= 37 + type mode = Minimal | Full module Types = struct @@ -283,6 +329,11 @@ let tx_data_size_limit_reached ~max_number_of_chunks ~tx_data = be contained within one of the chunks. *) (max_number_of_chunks - 1) +let is_contract_creation calldata to_ authorization_list = + Bytes.length calldata != 0 + && Option.is_none to_ + && List.is_empty authorization_list + let validate_tx_data_size ~max_number_of_chunks (transaction : Transaction.transaction) = let open Lwt_result_syntax in @@ -291,25 +342,100 @@ let validate_tx_data_size ~max_number_of_chunks return @@ Error "Transaction data exceeded the allowed size." else return (Ok ()) -let minimal_validation ~next_nonce ~max_number_of_chunks ctxt transaction - ~caller = +let initial_tx_gas_computation ~(transaction : Transaction.transaction) + ~is_prague_enabled = + let zero_bytes, nonzero_bytes = + Bytes.fold_left + (fun (zeros, nonzeros) c -> + if c = '\x00' then (zeros + 1, nonzeros) else (zeros, nonzeros + 1)) + (0, 0) + transaction.data + in + let tokens_in_calldata = + zero_bytes + (nonzero_bytes * K.non_zero_byte_multiplier) + in + let base_calldata_cost = tokens_in_calldata * K.standard_token_cost in + let access_list_accounts, access_list_storages = + List.fold_left + (fun (accounts, storages) (_, storage_slots) -> + (accounts + 1, storages + List.length storage_slots)) + (0, 0) + transaction.access_list + in + let access_list_accounts_cost = + access_list_accounts * K.access_list_address + in + let access_list_storages_costs = + access_list_storages * K.access_list_storage_key + in + let creation_cost = + if + is_contract_creation + transaction.data + transaction.to_ + transaction.authorization_list + then + let calldata_words = + let calldata_size = Bytes.length transaction.data in + (* Ensures rounding up when `calldata_size` is not a multiple of `K.word_size`. *) + (calldata_size + K.word_size - 1) / K.word_size + in + K.base_creation_cost + (K.initcode_word_cost * calldata_words) + else 0 + in + let prague_init_gas_cost, prague_floor_gas_cost = + if is_prague_enabled then + let prague_init_gas_cost = + List.length transaction.authorization_list + * K.eip7702_empty_account_cost + in + let prague_floor_gas_cost = + (tokens_in_calldata * K.total_cost_floor_per_token) + + K.base_intrisic_gas_cost + in + (prague_init_gas_cost, prague_floor_gas_cost) + else (0, 0) + in + let initial_gas = + K.base_intrisic_gas_cost + base_calldata_cost + access_list_accounts_cost + + access_list_storages_costs + creation_cost + prague_init_gas_cost + in + let floor_gas = prague_floor_gas_cost in + (initial_gas, floor_gas) + +(* Validation logic was taken from: + https://github.com/bluealloy/revm/blob/0ca6564f02004976f533cacf8821fed09d801e0a/crates/handler/src/validation.rs#L221 *) +let validate_minimum_gas_requirement ~session + ~(transaction : Transaction.transaction) = let open Lwt_result_syntax in - let (Session session) = ctxt.session in - let minimum_gas_limit = - let base_intrisic_gas_cost = Z.of_int 21_000 in - let da_inclusion_fees = - Fees.da_fees_gas_limit_overhead - ~da_fee_per_byte:(Qty session.da_fee_per_bytes) - ~minimum_base_fee_per_gas:session.minimum_base_fee_per_gas - transaction.Transaction.data - in - Z.add da_inclusion_fees base_intrisic_gas_cost + let is_prague_enabled = + is_prague_enabled ~storage_version:session.storage_version + in + let initial_gas, floor_gas = + initial_tx_gas_computation ~transaction ~is_prague_enabled + in + let gas_limit = transaction.gas_limit in + let* () = + let minimum_gas_limit_required = Z.of_int initial_gas in + (* Early exit for a transaction with a gas limit that can't cover the minimum + required. *) + when_ (gas_limit < minimum_gas_limit_required) @@ fun () -> + tzfail (Gas_limit_too_low {gas_limit; minimum_gas_limit_required}) in let* () = - (* Early exit: transaction with a gas limit below `minimum_gas_limit` is not allowed. *) - when_ (transaction.Transaction.gas_limit < minimum_gas_limit) @@ fun () -> - fail [Gas_limit_too_low (Z.to_string minimum_gas_limit)] + (* Check induced by EIP-7623, see https://eips.ethereum.org/EIPS/eip-7623. *) + let minimum_gas_limit_required = Z.of_int floor_gas in + when_ (is_prague_enabled && gas_limit < minimum_gas_limit_required) + @@ fun () -> + tzfail (Gas_limit_too_low {gas_limit; minimum_gas_limit_required}) in + return (Ok ()) + +let minimal_validation ~next_nonce ~max_number_of_chunks ctxt transaction + ~caller = + let open Lwt_result_syntax in + let (Session session) = ctxt.session in + let** () = validate_minimum_gas_requirement ~session ~transaction in let** () = validate_chain_id ctxt transaction in let** () = validate_nonce ~next_nonce transaction in let** () = validate_sender_not_a_contract session caller in @@ -424,7 +550,7 @@ module Handlers = struct let* () = when_ (txn.transaction_type = Transaction.Eip7702 - && session.storage_version < 37) + && not (is_prague_enabled ~storage_version:session.storage_version)) @@ fun () -> tzfail Prague_not_enabled in valid_transaction_object ctxt session ctxt.mode hash txn diff --git a/etherlink/bin_node/lib_dev/prevalidator.mli b/etherlink/bin_node/lib_dev/prevalidator.mli index ca65f2c8e2b6..25cfb66490ab 100644 --- a/etherlink/bin_node/lib_dev/prevalidator.mli +++ b/etherlink/bin_node/lib_dev/prevalidator.mli @@ -2,10 +2,13 @@ (* *) (* SPDX-License-Identifier: MIT *) (* Copyright (c) 2025 Nomadic Labs *) +(* Copyright (c) 2025 Functori *) (* *) (*****************************************************************************) -type error += Gas_limit_too_low of string | Prague_not_enabled +type error += + | Gas_limit_too_low of {gas_limit : Z.t; minimum_gas_limit_required : Z.t} + | Prague_not_enabled type mode = | Minimal -- GitLab From 282e929308a7d9d0ae3a431c4991a909434b2471 Mon Sep 17 00:00:00 2001 From: Rodi-Can Bozman Date: Mon, 1 Sep 2025 11:38:44 +0200 Subject: [PATCH 2/4] Etherlink: add a validation test for calldata cost not covered by gas limit Test focuses on calldata to illustrate the changes but could also be applied to other aspects of the transactions like access list costs etc. --- etherlink/tezt/tests/validate.ml | 36 ++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/etherlink/tezt/tests/validate.ml b/etherlink/tezt/tests/validate.ml index 77b6fa6a5d3c..9035c4ff29b3 100644 --- a/etherlink/tezt/tests/validate.ml +++ b/etherlink/tezt/tests/validate.ml @@ -2,7 +2,7 @@ (* *) (* SPDX-License-Identifier: MIT *) (* Copyright (c) 2024 Nomadic Labs *) -(* Copyright (c) 2024 Functori *) +(* Copyright (c) 2024-2025 Functori *) (* *) (*****************************************************************************) @@ -801,6 +801,37 @@ let test_base_gas_cost = ~error_msg:"The error message should be %R but got %L" ; unit +let test_validate_calldata_cost = + register + ~da_fee_per_byte:Wei.zero + ~title:"Validate that gas limit validation covers calldata cost" + ~tags:["calldata_cost"; "gas_limit"] + @@ fun _kernel sequencer tx_type -> + let source = Eth_account.bootstrap_accounts.(0) in + let make_tx ~legacy = + Cast.craft_tx + ~source_private_key:source.private_key + ~chain_id:1337 + ~nonce:0 + ~gas_price:1 + ~gas:100_000 + ~legacy + ~address:"0xd77420f73b4612a7a99dba8c2afd30a1886b0344" + ~arguments:[String.make 100_000 '1'] + ~value:Wei.zero + () + in + let* raw_tx = + match tx_type with + | Legacy -> make_tx ~legacy:true + | Eip1559 | Eip2930 -> make_tx ~legacy:false + in + let*@? err = Rpc.send_raw_transaction ~raw_tx sequencer in + Check.(err.message =~ rex " is insufficient to cover the transaction cost") + ~error_msg: + "The transaction has not enough gas to pay calldata cost, it should fail" ; + unit + let () = let all_types = [Legacy; Eip1559; Eip2930] in test_validate_compressed_sig [Legacy] ; @@ -816,4 +847,5 @@ let () = test_validate_custom_gas_limit_greater_than_maximum_gas_per_transaction [Legacy] ; test_sender_is_not_contract all_types ; - test_base_gas_cost all_types + test_base_gas_cost all_types ; + test_validate_calldata_cost all_types -- GitLab From 7a02ce658366f348080a68fabe9aa02bee35ab71 Mon Sep 17 00:00:00 2001 From: Rodi-Can Bozman Date: Mon, 1 Sep 2025 11:46:12 +0200 Subject: [PATCH 3/4] Etherlink: add a line in the node's changelogs --- etherlink/CHANGES_NODE.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/etherlink/CHANGES_NODE.md b/etherlink/CHANGES_NODE.md index fcb8e0bc4368..eec28e5d287d 100644 --- a/etherlink/CHANGES_NODE.md +++ b/etherlink/CHANGES_NODE.md @@ -14,6 +14,14 @@ after the blueprints have been committed to disk. (!18989) - Remove fallback for `/evm/v2/blueprint/` and `/evm/v2/blueprints/range` making version 0.30 deprecated. +- The sequencer now performs full gas limit prevalidation in line with Ethereum + standards. + Transactions are rejected if the specified gas limit is insufficient to cover: + * calldata cost, + * potential access list cost, and + * authorization list cost. + Previously the prevalidation was only checking if the requirement to cover the + minimum da fees and base intrisic gas cost were covered which was not enough. (!19149) ### Metrics changes -- GitLab From 8d16e29b581bc616bf9e5b86e7c49017f504f326 Mon Sep 17 00:00:00 2001 From: Rodi-Can Bozman Date: Tue, 2 Sep 2025 17:56:05 +0200 Subject: [PATCH 4/4] Etherlink: readapt existing test to refacto --- etherlink/tezt/tests/validate.ml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/etherlink/tezt/tests/validate.ml b/etherlink/tezt/tests/validate.ml index 9035c4ff29b3..54719db43194 100644 --- a/etherlink/tezt/tests/validate.ml +++ b/etherlink/tezt/tests/validate.ml @@ -700,7 +700,7 @@ let test_validate_gas_limit = let*@? err = Rpc.send_raw_transaction ~raw_tx:not_enough_gas_limit sequencer in - Check.(err.message =~ rex "Transaction with a gas limit below") + Check.(err.message =~ rex "Not enough gas for inclusion fees.") ~error_msg: "The transaction has not enough gas to pay da_fees, it should fail" ; (* This tx is the same as the valid_transaction in eip2930 but with some random entry for access_list *) @@ -797,7 +797,7 @@ let test_base_gas_cost = | Eip2930 | Eip1559 -> make_raw_tx ~legacy:false in let*@? err = Rpc.send_raw_transaction ~raw_tx sequencer in - Check.(err.message =~ rex "Transaction with a gas limit below") + Check.(err.message =~ rex "The provided gas limit") ~error_msg:"The error message should be %R but got %L" ; unit -- GitLab