From 165e33dc5c3b6cb6b06b5f48f3947097eb5cf463 Mon Sep 17 00:00:00 2001 From: Sylvain Ribstein Date: Thu, 13 Mar 2025 09:09:03 +0100 Subject: [PATCH 1/2] evm/node: add read fun in Evm_state --- etherlink/bin_node/lib_dev/evm_state.ml | 13 ++++++------- etherlink/bin_node/lib_dev/evm_state.mli | 4 ++++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/etherlink/bin_node/lib_dev/evm_state.ml b/etherlink/bin_node/lib_dev/evm_state.ml index 464493844707..135926f08a99 100644 --- a/etherlink/bin_node/lib_dev/evm_state.ml +++ b/etherlink/bin_node/lib_dev/evm_state.ml @@ -179,6 +179,11 @@ let exists evm_state key = let durable = Tezos_scoru_wasm.Durable.of_storage_exn durable in Tezos_scoru_wasm.Durable.exists durable key +let read state key = + let open Lwt_result_syntax in + let*! res = inspect state key in + return res + let kernel_version evm_state = let open Lwt_syntax in let+ version = inspect evm_state Durable_storage_path.kernel_version in @@ -330,13 +335,7 @@ let clear_delayed_inbox evm_state = let wasm_pvm_version state = Wasm_debugger.get_wasm_version state -let storage_version state = - let open Lwt_result_syntax in - let read key = - let*! res = inspect state key in - return res - in - Durable_storage.storage_version read +let storage_version state = Durable_storage.storage_version (read state) let irmin_store_path ~data_dir = Filename.Infix.(data_dir // "store") diff --git a/etherlink/bin_node/lib_dev/evm_state.mli b/etherlink/bin_node/lib_dev/evm_state.mli index 9fb495332aeb..1cd9c831e19a 100644 --- a/etherlink/bin_node/lib_dev/evm_state.mli +++ b/etherlink/bin_node/lib_dev/evm_state.mli @@ -54,6 +54,10 @@ val inspect : t -> string -> bytes option Lwt.t [evm_state]. *) val subkeys : t -> string -> string trace Lwt.t +(** [read evm_state key] returns the bytes stored under [key] in + [evm_state]. *) +val read : t -> string -> bytes option tzresult Lwt.t + (** [execute_and_inspect ~data_dir ?wasm_entrypoint ~config ~input evm_state] executes the [wasm_entrypoint] function (default to [kernel_run]) with [input] within the inbox of [evm_state], and -- GitLab From d037495db24144918813901339e9264fc8a56e64 Mon Sep 17 00:00:00 2001 From: Sylvain Ribstein Date: Wed, 12 Mar 2025 15:14:56 +0100 Subject: [PATCH 2/2] evm/node: block produce validate popped transaction with "local" state --- etherlink/CHANGES_NODE.md | 2 +- etherlink/bin_node/lib_dev/block_producer.ml | 62 +++++++- .../bin_node/lib_dev/block_producer_events.ml | 14 ++ etherlink/bin_node/lib_dev/sequencer.ml | 7 +- etherlink/bin_node/lib_dev/services.ml | 49 +++--- etherlink/bin_node/lib_dev/tx_queue.ml | 100 ++++++------ etherlink/bin_node/lib_dev/tx_queue.mli | 22 ++- etherlink/bin_node/lib_dev/validate.ml | 150 +++++++++++++++--- etherlink/bin_node/lib_dev/validate.mli | 21 +++ etherlink/tezt/lib/evm_node.ml | 12 ++ etherlink/tezt/lib/evm_node.mli | 6 + etherlink/tezt/tests/evm_sequencer.ml | 142 ++++++++++++++++- .../EVM node- list events regression.out | 15 ++ 13 files changed, 485 insertions(+), 117 deletions(-) diff --git a/etherlink/CHANGES_NODE.md b/etherlink/CHANGES_NODE.md index 663de3755576..8d62286b795b 100644 --- a/etherlink/CHANGES_NODE.md +++ b/etherlink/CHANGES_NODE.md @@ -57,7 +57,7 @@ you start using them, you probably want to use `octez-evm-node check config This limits is for pending transactions the node has seen. configurable with `tx_per_addr_limit`. (!16903) - An sequencer EVM node can uses the tx_queue to speed the inclusion - of transaction. (!17134 !17100 !17109) + of transaction. (!17134 !17100 !17109 !17211) - `tx_queue` now has a maximum number of transactions. (!17083) - Observer nodes can now be run with `periodic_snapshot_path` defined in the configuration. It exports a snapshot to the given path every time they diff --git a/etherlink/bin_node/lib_dev/block_producer.ml b/etherlink/bin_node/lib_dev/block_producer.ml index 201d8b79a273..7492c03dcb79 100644 --- a/etherlink/bin_node/lib_dev/block_producer.ml +++ b/etherlink/bin_node/lib_dev/block_producer.ml @@ -186,6 +186,60 @@ let produce_block_with_transactions ~sequencer_key ~cctxt ~timestamp in return confirmed_txs +let validate_tx ~maximum_cumulative_size (current_size, validation_state) raw_tx + (tx_object : Ethereum_types.legacy_transaction_object) = + let open Lwt_result_syntax in + let new_size = current_size + String.length raw_tx in + if new_size > maximum_cumulative_size then return `Stop + else + let*? transaction = + (* TODO: https://gitlab.com/tezos/tezos/-/issues/7785 + This decoding can be removed when switching the codebase to + transaction_object. It's ok for a first version. *) + Result.map_error (fun msg -> [error_of_fmt "%s" msg]) + @@ Transaction.decode raw_tx + in + let* validation_state_res = + Validate.validate_balance_gas_nonce_with_validation_state + validation_state + ~caller:tx_object.from + transaction + in + match validation_state_res with + | Ok validation_state -> return (`Keep (new_size, validation_state)) + | Error msg -> + let*! () = + Block_producer_events.transaction_rejected tx_object.hash msg + in + return `Drop + +let tx_queue_pop_valid_tx (head_info : Evm_context.head) + ~maximum_cumulative_size = + let open Lwt_result_syntax in + let read = Evm_state.read head_info.evm_state in + let* base_fee_per_gas = Durable_storage.base_fee_per_gas read in + let* maximum_gas_limit = Durable_storage.maximum_gas_per_transaction read in + let* da_fee_per_byte = Durable_storage.da_fee_per_byte read in + let config = + Validate. + { + base_fee_per_gas; + maximum_gas_limit; + da_fee_per_byte; + next_nonce = (fun addr -> Durable_storage.nonce read addr); + balance = (fun addr -> Durable_storage.balance read addr); + } + in + let initial_validation_state = + ( 0, + Validate. + {config; addr_balance = String.Map.empty; addr_nonce = String.Map.empty} + ) + in + Tx_queue.pop_transactions + ~validate_tx:(validate_tx ~maximum_cumulative_size) + ~initial_validation_state + (** Produces a block if we find at least one valid transaction in the transaction pool or if [force] is true. *) let produce_block_if_needed ~cctxt ~smart_rollup_address ~sequencer_key ~force @@ -198,14 +252,10 @@ let produce_block_if_needed ~cctxt ~smart_rollup_address ~sequencer_key ~force if remaining_cumulative_size <= minimum_ethereum_transaction_size then return [] else if uses_tx_queue then - (* TODO: https://gitlab.com/tezos/tezos/-/merge_requests/17211 - Validates transactions with regards to balance for - example (with_state validation) *) - Tx_queue.pop_transactions + tx_queue_pop_valid_tx + head_info ~maximum_cumulative_size:remaining_cumulative_size else - (* When the tx_pool is removed, we could keep the sequence instead - of creating a list in the popped transaction of the tx_queue. *) Tx_pool.pop_transactions ~maximum_cumulative_size:remaining_cumulative_size in diff --git a/etherlink/bin_node/lib_dev/block_producer_events.ml b/etherlink/bin_node/lib_dev/block_producer_events.ml index 0334ef0954e7..2cb27a80a61f 100644 --- a/etherlink/bin_node/lib_dev/block_producer_events.ml +++ b/etherlink/bin_node/lib_dev/block_producer_events.ml @@ -42,6 +42,17 @@ module Event = struct ~msg:"transaction pool and block production are locked" ~level:Error () + + let transaction_rejected = + declare_2 + ~section + ~name:"block_producer_transaction_rejected" + ~msg:"transaction {tx_hash} is not valid with current state: {error}" + ~level:Debug + ~pp1:(fun fmt Ethereum_types.(Hash (Hex h)) -> + Format.fprintf fmt "%10s" h) + ("tx_hash", Ethereum_types.hash_encoding) + ("error", Data_encoding.string) end let transaction_selected ~hash = @@ -52,3 +63,6 @@ let started () = Internal_event.Simple.emit Event.started () let shutdown () = Internal_event.Simple.emit Event.shutdown () let production_locked () = Internal_event.Simple.emit Event.production_locked () + +let transaction_rejected tx_hash error = + Internal_event.Simple.emit Event.transaction_rejected (tx_hash, error) diff --git a/etherlink/bin_node/lib_dev/sequencer.ml b/etherlink/bin_node/lib_dev/sequencer.ml index 363ea5beeec7..8c438d8e2483 100644 --- a/etherlink/bin_node/lib_dev/sequencer.ml +++ b/etherlink/bin_node/lib_dev/sequencer.ml @@ -271,7 +271,12 @@ let main ~data_dir ?(genesis_timestamp = Misc.now ()) ~cctxt ~rpc_server_family: (if enable_multichain then Rpc_types.Multichain_sequencer_rpc_server else Rpc_types.Single_chain_node_rpc_server EVM) - Full + (* When the tx_queue is enabled the validation is done in the + block_producer instead of in the RPC. This allows for a more + accurate validation as it's delayed up to when the block is + created. *) + (if Configuration.is_tx_queue_enabled configuration then Stateless + else Full) configuration (backend, smart_rollup_address_typed) in diff --git a/etherlink/bin_node/lib_dev/services.ml b/etherlink/bin_node/lib_dev/services.ml index 9923fb2774f5..6b1e4fff1566 100644 --- a/etherlink/bin_node/lib_dev/services.ml +++ b/etherlink/bin_node/lib_dev/services.ml @@ -589,10 +589,7 @@ let dispatch_request (rpc_server_family : Rpc_types.rpc_server_family) match block_param with | Ethereum_types.Block_parameter.(Block_parameter Pending) -> let* nonce = - if - Option.is_some - config.experimental_features.enable_tx_queue - then + if Configuration.is_tx_queue_enabled config then let* next_nonce = Backend_rpc.nonce address block_param in let next_nonce = Option.value ~default:Qty.zero next_nonce @@ -963,25 +960,31 @@ let dispatch_private_request (rpc_server_family : Rpc_types.rpc_server_family) ( (transaction_object : Ethereum_types.legacy_transaction_object), raw_txn ) = let* is_valid = - let* mode = Tx_pool.mode () in - match mode with - | Sequencer -> - Validate.is_tx_valid - (module Backend_rpc) - ~mode:With_state - raw_txn - | _ -> - let* next_nonce = - Backend_rpc.nonce - transaction_object.from - (Block_parameter Latest) - in - let next_nonce = - match next_nonce with - | None -> Ethereum_types.Qty Z.zero - | Some next_nonce -> next_nonce - in - return @@ Ok (next_nonce, transaction_object) + let get_nonce () = + let* next_nonce = + Backend_rpc.nonce + transaction_object.from + (Block_parameter Latest) + in + let next_nonce = + match next_nonce with + | None -> Ethereum_types.Qty Z.zero + | Some next_nonce -> next_nonce + in + return @@ Ok (next_nonce, transaction_object) + in + (* If the tx_queue is enabled the `with_state` validation + will be done in the block producer *) + if Configuration.is_tx_queue_enabled config then get_nonce () + else + let* mode = Tx_pool.mode () in + match mode with + | Sequencer -> + Validate.is_tx_valid + (module Backend_rpc) + ~mode:With_state + raw_txn + | _ -> get_nonce () in match is_valid with | Error err -> diff --git a/etherlink/bin_node/lib_dev/tx_queue.ml b/etherlink/bin_node/lib_dev/tx_queue.ml index 3fccc8630ac0..6099f6a0524e 100644 --- a/etherlink/bin_node/lib_dev/tx_queue.ml +++ b/etherlink/bin_node/lib_dev/tx_queue.ml @@ -386,7 +386,12 @@ module Request = struct | Is_locked : (bool, tztrace) t | Content : (Ethereum_types.txpool, tztrace) t | Pop_transactions : { - maximum_cumulative_size : int; + validation_state : 'a; + validate_tx : + 'a -> + string -> + Ethereum_types.legacy_transaction_object -> + [`Keep of 'a | `Drop | `Stop] tzresult Lwt.t; } -> ((string * Ethereum_types.legacy_transaction_object) list, tztrace) t | Confirm_transactions : { @@ -486,12 +491,10 @@ module Request = struct case Json_only ~title:"Pop_transactions" - (obj2 - (req "request" (constant "pop_transactions")) - (req "maximum_cumulatize_size" int31)) + (obj1 (req "request" (constant "pop_transactions"))) (function - | View (Pop_transactions {maximum_cumulative_size}) -> - Some ((), maximum_cumulative_size) + | View (Pop_transactions {validation_state = _; validate_tx = _}) -> + Some () | _ -> None) (fun _ -> assert false); case @@ -525,11 +528,8 @@ module Request = struct | Unlock_transactions -> Format.fprintf fmt "Unlocking the transactions" | Is_locked -> Format.fprintf fmt "Checking if the tx queue is locked" | Content -> fprintf fmt "Content" - | Pop_transactions {maximum_cumulative_size} -> - fprintf - fmt - "Popping transactions of maximum cumulative size %d bytes" - maximum_cumulative_size + | Pop_transactions {validation_state = _; validate_tx = _} -> + fprintf fmt "Popping transactions with validation function" | Confirm_transactions _ -> fprintf fmt "Confirming transactions" end @@ -633,33 +633,43 @@ let unlock_transactions state = state.locked <- false let is_locked state = state.locked -let pop_queue_until state ~maximum_cumulative_size = +let pop_queue_until state ~validation_state ~validate_tx = let open Lwt_result_syntax in - let rec aux (current_size, rev_selected) = + let rec aux validation_state rev_selected = match Queue.peek_opt state.queue with | None -> return rev_selected - | Some {hash; payload; queue_callback} -> + | Some {hash; payload; queue_callback} -> ( let raw_tx = Ethereum_types.hex_to_bytes payload in - let new_size = current_size + String.length raw_tx in - if new_size <= maximum_cumulative_size then - (* Drop the tx because it's selected. *) - let _ = Queue.take state.queue in - let tx_object = Tx_object.find state.tx_object hash in - match tx_object with - | None -> - (* Drop that tx because no tx_object associated. this is - an inpossible case, we log it to investigate. *) - let*! () = Tx_queue_events.missing_tx_object hash in - let*! () = queue_callback `Refused in - aux (current_size, rev_selected) - | Some tx_object -> - let rev_selected = - ((raw_tx, tx_object), queue_callback) :: rev_selected - in - aux (new_size, rev_selected) - else return rev_selected + let tx_object = Tx_object.find state.tx_object hash in + match tx_object with + | None -> + (* Drop that tx because no tx_object associated. this is + an inpossible case, we log it to investigate. *) + let*! () = Tx_queue_events.missing_tx_object hash in + let _ = Queue.take state.queue in + let*! () = queue_callback `Refused in + aux validation_state rev_selected + | Some tx_object -> ( + let* is_valid = validate_tx validation_state raw_tx tx_object in + match is_valid with + | `Stop -> return rev_selected + (* `Stop means that we don't pop transaction anymore. We + don't remove the last peek tx because it could be valid + for another call. *) + | `Drop -> + (* `Drop, the current tx was evaluated and was refused + by the caller. *) + let _ = Queue.take state.queue in + let*! () = queue_callback `Refused in + aux validation_state rev_selected + | `Keep validation_state -> + (* `Keep, the current tx was evaluated and was validated + by the caller. *) + let _ = Queue.take state.queue in + let*! () = queue_callback `Accepted in + aux validation_state ((raw_tx, tx_object) :: rev_selected))) in - let* rev_selected = aux (0, []) in + let* rev_selected = aux validation_state [] in return @@ List.rev rev_selected module Handlers = struct @@ -872,26 +882,10 @@ module Handlers = struct in return {pending; queued} - | Pop_transactions {maximum_cumulative_size} -> + | Pop_transactions {validation_state; validate_tx} -> let open Lwt_result_syntax in if is_locked state then return [] - else - let* selected = pop_queue_until state ~maximum_cumulative_size in - let*! selected = - List.map_s - (fun (tx, callback) -> - let open Lwt_syntax in - let* () = callback `Accepted in - return tx) - selected - in - (* All transactions popped are considered `Accepted, and are - added to the pending state. The only consumer of that - request is the block producer, a local worker that will - process all popped transaction, and confirm only - transactions that were included in a block with - [Confirm_transactions] *) - return selected + else pop_queue_until state ~validate_tx ~validation_state | Confirm_transactions {confirmed_txs; clear_pending_queue_after} -> let*! () = Seq.S.iter @@ -1071,12 +1065,12 @@ let is_locked () = let*? worker = Lazy.force worker in Worker.Queue.push_request_and_wait worker Is_locked |> handle_request_error -let pop_transactions ~maximum_cumulative_size = +let pop_transactions ~validate_tx ~initial_validation_state = let open Lwt_result_syntax in let*? w = Lazy.force worker in Worker.Queue.push_request_and_wait w - (Pop_transactions {maximum_cumulative_size}) + (Pop_transactions {validate_tx; validation_state = initial_validation_state}) |> handle_request_error let confirm_transactions ~clear_pending_queue_after ~confirmed_txs = diff --git a/etherlink/bin_node/lib_dev/tx_queue.mli b/etherlink/bin_node/lib_dev/tx_queue.mli index 533f61afca79..b7e243108054 100644 --- a/etherlink/bin_node/lib_dev/tx_queue.mli +++ b/etherlink/bin_node/lib_dev/tx_queue.mli @@ -109,17 +109,23 @@ val is_locked : unit -> bool tzresult Lwt.t are not equal to {!Tx_pool.get_tx_pool_content} *) val content : unit -> Ethereum_types.txpool tzresult Lwt.t -(** [pop_transactions ~maximum_cumulative_size] pops as much valid - transactions as possible from the pool, until their cumulative - size exceeds [maximum_cumulative_size]. +(** [pop_transactions ~validate_tx ~initial_validation_state] pops as + many transactions as possible from the queue, validating them with + [validate_tx]. If [validate_tx] returns [`Keep validation_state] + then the evaluated transaction is popped, else if it returns + [`Drop], it's considered invalid and it's callback is called with + [`Refused]. If [validate_tx] returns [`Stop] then the caller has + enough transactions. If the tx_queue is locked (c.f. {!lock_transactions} then returns - the empty list. - - All returned transaction are considered as accepted and all - associated callbacks are called with [`Accepted]. *) + the empty list. *) val pop_transactions : - maximum_cumulative_size:int -> + validate_tx: + ('a -> + string -> + Ethereum_types.legacy_transaction_object -> + [`Keep of 'a | `Drop | `Stop] tzresult Lwt.t) -> + initial_validation_state:'a -> (string * Ethereum_types.legacy_transaction_object) list tzresult Lwt.t (** [confirm_transactions ~clear_pending_queue_after ~confirmed_txs] diff --git a/etherlink/bin_node/lib_dev/validate.ml b/etherlink/bin_node/lib_dev/validate.ml index c24b8c5966cf..59628e2d25da 100644 --- a/etherlink/bin_node/lib_dev/validate.ml +++ b/etherlink/bin_node/lib_dev/validate.ml @@ -34,22 +34,13 @@ let validate_nonce ~next_nonce:(Qty next_nonce) if transaction.nonce >= next_nonce then return (Ok ()) else return (Error "Nonce too low") -let validate_gas_limit (module Backend_rpc : Services_backend_sig.S) +let validate_gas_limit ~maximum_gas_limit:(Qty maximum_gas_limit) + ~da_fee_per_byte ~base_fee_per_gas:(Qty gas_price) (transaction : Transaction.transaction) : (unit, string) result tzresult Lwt.t = let open Lwt_result_syntax in (* Constants defined in the kernel: *) let gas_limit = transaction.gas_limit in - let* state = Backend_rpc.Reader.get_state () in - let* (Qty maximum_gas_limit) = - Durable_storage.maximum_gas_per_transaction (Backend_rpc.Reader.read state) - in - let* da_fee_per_byte = - Durable_storage.da_fee_per_byte (Backend_rpc.Reader.read state) - in - let* (Qty gas_price) = - Durable_storage.base_fee_per_gas (Backend_rpc.Reader.read state) - in let gas_for_da_fees = Fees.gas_for_fees ~da_fee_per_byte @@ -81,10 +72,9 @@ let validate_sender_not_a_contract (module Backend_rpc : Services_backend_sig.S) if code = "" then return (Ok ()) else return (Error "Sender is a contract which is not possible") -let validate_max_fee_per_gas (module Backend_rpc : Services_backend_sig.S) +let validate_max_fee_per_gas ~base_fee_per_gas:(Qty base_fee_per_gas) (transaction : Transaction.transaction) = let open Lwt_result_syntax in - let* (Qty base_fee_per_gas) = Backend_rpc.base_fee_per_gas () in if transaction.max_fee_per_gas >= base_fee_per_gas then return (Ok ()) else return (Error "Max gas fee too low") @@ -100,7 +90,7 @@ let validate_balance_is_enough (transaction : Transaction.transaction) ~balance in if gas_cost > balance then return (Error "Cannot prepay transaction.") else if total_cost > balance then return (Error "Not enough funds") - else return (Ok ()) + else return (Ok total_cost) let validate_stateless ~next_nonce backend_rpc transaction ~caller = let open Lwt_result_syntax in @@ -109,16 +99,44 @@ let validate_stateless ~next_nonce backend_rpc transaction ~caller = let** () = validate_sender_not_a_contract backend_rpc caller in return (Ok ()) -let validate_with_state (module Backend_rpc : Services_backend_sig.S) - transaction ~caller = +let validate_balance_and_gas ~base_fee_per_gas ~maximum_gas_limit + ~da_fee_per_byte ~transaction ~from_balance:(Qty from_balance) = let open Lwt_result_syntax in - let* (Qty balance) = + let** () = validate_max_fee_per_gas ~base_fee_per_gas transaction in + let** () = + validate_gas_limit + ~maximum_gas_limit + ~da_fee_per_byte + ~base_fee_per_gas + transaction + in + let** total_cost = + validate_balance_is_enough transaction ~balance:from_balance + in + return (Ok total_cost) + +let validate_with_state_from_backend + (module Backend_rpc : Services_backend_sig.S) transaction ~caller = + let open Lwt_result_syntax in + let* from_balance = Backend_rpc.balance caller Block_parameter.(Block_parameter Latest) in - let backend_rpc = (module Backend_rpc : Services_backend_sig.S) in - let** () = validate_max_fee_per_gas backend_rpc transaction in - let** () = validate_gas_limit backend_rpc transaction in - let** () = validate_balance_is_enough transaction ~balance in + let* base_fee_per_gas = Backend_rpc.base_fee_per_gas () in + let* state = Backend_rpc.Reader.get_state () in + let* maximum_gas_limit = + Durable_storage.maximum_gas_per_transaction (Backend_rpc.Reader.read state) + in + let* da_fee_per_byte = + Durable_storage.da_fee_per_byte (Backend_rpc.Reader.read state) + in + let** _total_cost = + validate_balance_and_gas + ~base_fee_per_gas + ~maximum_gas_limit + ~da_fee_per_byte + ~transaction + ~from_balance + in return (Ok ()) type validation_mode = Stateless | With_state | Full @@ -137,10 +155,10 @@ let valid_transaction_object ~backend_rpc ~hash ~mode tx = let** () = match mode with | Stateless -> validate_stateless backend_rpc ~next_nonce tx ~caller - | With_state -> validate_with_state backend_rpc tx ~caller + | With_state -> validate_with_state_from_backend backend_rpc tx ~caller | Full -> let** () = validate_stateless ~next_nonce backend_rpc tx ~caller in - let** () = validate_with_state backend_rpc tx ~caller in + let** () = validate_with_state_from_backend backend_rpc tx ~caller in return (Ok ()) in @@ -151,3 +169,89 @@ let is_tx_valid ((module Backend_rpc : Services_backend_sig.S) as backend_rpc) let hash = Ethereum_types.hash_raw_tx tx_raw in let**? tx = Transaction.decode tx_raw in valid_transaction_object ~backend_rpc ~hash ~mode tx + +type validation_config = { + base_fee_per_gas : Ethereum_types.quantity; + maximum_gas_limit : Ethereum_types.quantity; + da_fee_per_byte : Ethereum_types.quantity; + next_nonce : + Ethereum_types.address -> Ethereum_types.quantity option tzresult Lwt.t; + balance : Ethereum_types.address -> Ethereum_types.quantity tzresult Lwt.t; +} + +type validation_state = { + config : validation_config; + addr_balance : Z.t String.Map.t; + addr_nonce : Z.t String.Map.t; +} + +let validate_balance_gas_nonce_with_validation_state validation_state + ~(caller : Ethereum_types.address) (transaction : Transaction.transaction) : + (validation_state, string) result tzresult Lwt.t = + let open Lwt_result_syntax in + let (Address (Hex caller_str)) = caller in + let* next_nonce = + let nonce = String.Map.find caller_str validation_state.addr_nonce in + match nonce with + | Some nonce -> return nonce + | None -> ( + let* nonce = validation_state.config.next_nonce caller in + match nonce with + | Some (Qty nonce) -> return nonce + | None -> return Z.zero) + in + let** () = + let tx_nonce = transaction.nonce in + if Z.equal tx_nonce next_nonce then return (Ok ()) + else return (Error "Transaction nonce is not the expected nonce.") + in + let* from_balance = + let from_balance = + String.Map.find caller_str validation_state.addr_balance + in + match from_balance with + | Some balance -> return balance + | None -> + let* (Qty balance) = validation_state.config.balance caller in + return balance + in + let** total_cost = + validate_balance_and_gas + ~base_fee_per_gas:validation_state.config.base_fee_per_gas + ~maximum_gas_limit:validation_state.config.maximum_gas_limit + ~da_fee_per_byte:validation_state.config.da_fee_per_byte + ~transaction + ~from_balance:(Qty from_balance) + in + let* addr_balance = + match transaction.to_ with + | None -> return validation_state.addr_balance + | Some to_ -> + let (`Hex to_) = Hex.of_bytes to_ in + let to_balance = String.Map.find to_ validation_state.addr_balance in + let* to_balance = + match to_balance with + | Some balance -> return balance + | None -> + let* (Qty balance) = + validation_state.config.balance (Address (Hex to_)) + in + return balance + in + let new_to_balance = Z.add transaction.value to_balance in + let addr_balance = + String.Map.add to_ new_to_balance validation_state.addr_balance + in + return addr_balance + in + let validation_state = + let new_from_balance = Z.sub from_balance total_cost in + let addr_balance = + String.Map.add caller_str new_from_balance addr_balance + in + let addr_nonce = + String.Map.add caller_str (Z.succ next_nonce) validation_state.addr_nonce + in + {validation_state with addr_balance; addr_nonce} + in + return (Ok validation_state) diff --git a/etherlink/bin_node/lib_dev/validate.mli b/etherlink/bin_node/lib_dev/validate.mli index 587e5052e8d8..e13e1e606086 100644 --- a/etherlink/bin_node/lib_dev/validate.mli +++ b/etherlink/bin_node/lib_dev/validate.mli @@ -24,3 +24,24 @@ val is_tx_valid : result tzresult Lwt.t + +type validation_config = { + base_fee_per_gas : Ethereum_types.quantity; + maximum_gas_limit : Ethereum_types.quantity; + da_fee_per_byte : Ethereum_types.quantity; + next_nonce : + Ethereum_types.address -> Ethereum_types.quantity option tzresult Lwt.t; + balance : Ethereum_types.address -> Ethereum_types.quantity tzresult Lwt.t; +} + +type validation_state = { + config : validation_config; + addr_balance : Z.t String.Map.t; + addr_nonce : Z.t String.Map.t; +} + +val validate_balance_gas_nonce_with_validation_state : + validation_state -> + caller:Ethereum_types.address -> + Transaction.transaction -> + (validation_state, string) result tzresult Lwt.t diff --git a/etherlink/tezt/lib/evm_node.ml b/etherlink/tezt/lib/evm_node.ml index bc05c2f66f4a..cde1daeb1eec 100644 --- a/etherlink/tezt/lib/evm_node.ml +++ b/etherlink/tezt/lib/evm_node.ml @@ -598,6 +598,18 @@ let wait_for_tx_queue_cleared ?timeout evm_node = wait_for_event ?timeout evm_node ~event:"tx_queue_cleared.v0" @@ Fun.const (Some ()) +let wait_for_block_producer_rejected_transaction ?timeout ?hash evm_node = + wait_for_event + ?timeout + evm_node + ~event:"block_producer_transaction_rejected.v0" + @@ fun json -> + let found_hash = JSON.(json |-> "tx_hash" |> as_string) in + let reason = JSON.(json |-> "error" |> as_string) in + match hash with + | Some hash -> if found_hash = hash then Some reason else None + | None -> Some reason + let wait_for_split ?level evm_node = wait_for_event evm_node ~event:"evm_context_gc_split.v0" @@ fun json -> let event_level = JSON.(json |-> "level" |> as_int) in diff --git a/etherlink/tezt/lib/evm_node.mli b/etherlink/tezt/lib/evm_node.mli index aa078eb3aa77..3d018144e3d0 100644 --- a/etherlink/tezt/lib/evm_node.mli +++ b/etherlink/tezt/lib/evm_node.mli @@ -445,6 +445,12 @@ val wait_for_tx_queue_injecting_transaction : ?timeout:float -> t -> int Lwt.t (** [wait_for_tx_queue_cleared ?timeout evm_node] waits for the [tx_queue_cleared.v0]. *) val wait_for_tx_queue_cleared : ?timeout:float -> t -> unit Lwt.t +(** [wait_for_block_producer_rejected_transaction ?timeout ?hash + evm_node] waits for the [block_producer_rejected_transaction.v0] + and returns the reason for the tx to be rejected. *) +val wait_for_block_producer_rejected_transaction : + ?timeout:float -> ?hash:string -> t -> string Lwt.t + (** [wait_for_shutdown ?can_terminate evm_node] waits until a node terminates and return its status. If the node is not running, make the test fail. If [can_terminate] is `true` and the node was already terminated, returns diff --git a/etherlink/tezt/tests/evm_sequencer.ml b/etherlink/tezt/tests/evm_sequencer.ml index 33d2b3a4bbc0..09452aaaee0e 100644 --- a/etherlink/tezt/tests/evm_sequencer.ml +++ b/etherlink/tezt/tests/evm_sequencer.ml @@ -11449,7 +11449,7 @@ let test_tx_queue_nonce = let* () = let*@ included_nb_txs = produce_block sequencer in Check.( - (included_nb_txs = nb_txs + 2) + (included_nb_txs = nb_txs + 1) int ~__LOC__ ~error_msg:"Produce block included %L transaction expected %R") ; @@ -12039,6 +12039,143 @@ let test_fa_deposit_and_withdrawals_events = capture_logs ~header:"FA Withdrawal" receipt.logs ; unit +let test_block_producer_validation = + register_all + ~tags:["observer"; "tx_queue"; "validation"] + ~time_between_blocks:Nothing + ~kernels:[Latest] (* node only test *) + ~use_threshold_encryption:Register_without_feature + ~use_dal:Register_without_feature + ~websockets:false + ~enable_tx_queue:true (* enables it in the sequencer *) + ~title:"Test part of the validation is done when producing blocks." + @@ fun {sequencer; observer; _} _protocol -> + let* () = + let*@ _ = produce_block sequencer in + unit + and* () = Evm_node.wait_for_blueprint_applied observer 1 in + let* () = Evm_node.terminate observer in + + let* () = + (* modify the config of the observer. *) + Evm_node.Config_file.update observer + @@ Evm_node.patch_config_with_experimental_feature + ~enable_tx_queue:true + ~tx_queue_config: + { + max_size = 1000; + max_lifespan = 100000 (* absurd value so no TX are dropped *); + tx_per_addr_limit = 1000000 (* absurd value so no TX are limited *); + } + () + in + let* () = Evm_node.run observer in + let send_and_wait_sequencer_receive ~raw_tx = + let wait_sequencer_see_tx = + Evm_node.wait_for_tx_queue_add_transaction sequencer + in + let* hash = + let*@ hash = Rpc.send_raw_transaction ~raw_tx observer in + return hash + and* _ = wait_sequencer_see_tx in + return hash + in + + (* helper to craft a tx with given nonce. *) + let gas_price = 1_000_000_000 in + let gas = 23_000 in + let gas_cost = Wei.of_eth_int (1 * 23) in + let*@ balance_b0 = + Rpc.get_balance ~address:Eth_account.bootstrap_accounts.(0).address observer + in + let*@ balance_b1 = + Rpc.get_balance ~address:Eth_account.bootstrap_accounts.(1).address observer + in + + let* raw_tx_empty_account_bO = + Cast.craft_tx + ~source_private_key:Eth_account.bootstrap_accounts.(0).private_key + ~chain_id:1337 + ~nonce:0 + ~gas_price + ~gas + ~value:Wei.(balance_b0 - gas_cost) + ~address:Eth_account.bootstrap_accounts.(1).address + () + in + let* raw_tx_empty_account_b1 = + let*@ _ = + Rpc.get_balance + ~address:Eth_account.bootstrap_accounts.(1).address + observer + in + Cast.craft_tx + ~source_private_key:Eth_account.bootstrap_accounts.(1).private_key + ~chain_id:1337 + ~nonce:0 + ~gas_price + ~gas + ~value:Wei.(balance_b0 + balance_b1 - (gas_cost * Z.of_int 2)) + ~address:Eth_account.bootstrap_accounts.(2).address + () + in + let* raw_tx_invalid_value = + Cast.craft_tx + ~source_private_key:Eth_account.bootstrap_accounts.(0).private_key + ~chain_id:1337 + ~nonce:1 + ~gas_price + ~gas + ~value:(Wei.of_eth_int 100) + ~address:Eth_account.bootstrap_accounts.(1).address + () + in + let* raw_tx_invalid_nonce = + Cast.craft_tx + ~source_private_key:Eth_account.bootstrap_accounts.(1).private_key + ~chain_id:1337 + ~nonce:10 + ~gas_price + ~gas + ~value:Wei.one + ~address:Eth_account.bootstrap_accounts.(2).address + () + in + let* _hash = + (* tx included *) + send_and_wait_sequencer_receive ~raw_tx:raw_tx_empty_account_bO + in + let* invalid_balance_hash1 = + send_and_wait_sequencer_receive ~raw_tx:raw_tx_invalid_value + in + let* _hash = + (* The transaction is included because the balance of b1 is + covered by the preceding transaction, + `raw_tx_empty_account_b0`. *) + send_and_wait_sequencer_receive ~raw_tx:raw_tx_empty_account_b1 + in + let* invalid_nonce_hash2 = + send_and_wait_sequencer_receive ~raw_tx:raw_tx_invalid_nonce + in + let* txs = + let*@ txs = produce_block sequencer in + return txs + and* reason1 = + Evm_node.wait_for_block_producer_rejected_transaction + ~hash:invalid_balance_hash1 + sequencer + and* reason2 = + Evm_node.wait_for_block_producer_rejected_transaction + ~hash:invalid_nonce_hash2 + sequencer + in + Check.(reason1 =~ rex "Not enough funds") + ~error_msg:"transaction rejected for invalid reason, found %L, expected %R" ; + Check.(reason2 =~ rex "Transaction nonce is not the expected nonce") + ~error_msg:"transaction rejected for invalid reason, found %L, expected %R" ; + Check.((txs = 2) int ~error_msg:"block has %L, but expected %R") ; + unit + let protocols = Protocol.all let () = @@ -12198,4 +12335,5 @@ let () = test_deposit_event [Alpha] ; test_withdrawal_events [Alpha] ; test_fa_deposit_and_withdrawals_events [Alpha] ; - test_current_level protocols + test_current_level protocols ; + test_block_producer_validation [Alpha] diff --git a/etherlink/tezt/tests/expected/evm_rollup.ml/EVM node- list events regression.out b/etherlink/tezt/tests/expected/evm_rollup.ml/EVM node- list events regression.out index a5852900dece..65ef4ea31ad0 100644 --- a/etherlink/tezt/tests/expected/evm_rollup.ml/EVM node- list events regression.out +++ b/etherlink/tezt/tests/expected/evm_rollup.ml/EVM node- list events regression.out @@ -48,6 +48,21 @@ block_producer_transaction_injected: contain invalid byte sequences. */ string || { "invalid_utf8_string": [ integer ∈ [0, 255] ... ] } +block_producer_transaction_rejected: + description: transaction {tx_hash} is not valid with current state: {error} + level: debug + section: evm_node.dev + json format: + { /* block_producer_transaction_rejected version 0 */ + "block_producer_transaction_rejected.v0": + { "tx_hash": $unistring, + "error": $unistring } } + $unistring: + /* Universal string representation + Either a plain UTF8 string, or a sequence of bytes for strings that + contain invalid byte sequences. */ + string || { "invalid_utf8_string": [ integer ∈ [0, 255] ... ] } + blueprint_application: description: head is now {level}, applied in {process_time}{timestamp} level: notice -- GitLab