From bf302cc08df0f45772bfb4248a7892037d231aa0 Mon Sep 17 00:00:00 2001 From: Sylvain Ribstein Date: Fri, 28 Feb 2025 13:26:21 +0100 Subject: [PATCH 1/4] evm/node: validation returns next nonce from backend --- etherlink/bin_node/lib_dev/services.ml | 21 +++++++++++++---- etherlink/bin_node/lib_dev/validate.ml | 30 +++++++++++++++---------- etherlink/bin_node/lib_dev/validate.mli | 9 ++++++-- 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/etherlink/bin_node/lib_dev/services.ml b/etherlink/bin_node/lib_dev/services.ml index 349c39ec2d93..f8637dfd1136 100644 --- a/etherlink/bin_node/lib_dev/services.ml +++ b/etherlink/bin_node/lib_dev/services.ml @@ -678,7 +678,7 @@ let dispatch_request (rpc : Configuration.rpc) Tx_pool_events.invalid_transaction ~transaction:tx_raw in rpc_error (Rpc_errors.transaction_rejected err None) - | Ok transaction_object -> ( + | Ok (_next_nonce, transaction_object) -> ( let* tx_hash = if Configuration.is_tx_queue_enabled config then let* () = Tx_queue.inject transaction_object tx_raw in @@ -887,7 +887,9 @@ let dispatch_private_request (rpc : Configuration.rpc) build ~f module_ parameters | Method (Inject_transaction.Method, module_) -> let open Lwt_result_syntax in - let f (transaction_object, raw_txn) = + let f + ( (transaction_object : Ethereum_types.legacy_transaction_object), + raw_txn ) = let* is_valid = let* mode = Tx_pool.mode () in match mode with @@ -896,14 +898,25 @@ let dispatch_private_request (rpc : Configuration.rpc) (module Backend_rpc) ~mode:With_state raw_txn - | _ -> return @@ Ok transaction_object + | _ -> + 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 match is_valid with | Error err -> let transaction = Ethereum_types.hex_encode_string raw_txn in let*! () = Tx_pool_events.invalid_transaction ~transaction in rpc_error (Rpc_errors.transaction_rejected err None) - | Ok transaction_object -> ( + | Ok (_next_nonce, transaction_object) -> ( let* tx_hash = if Configuration.is_tx_queue_enabled config then let transaction = Ethereum_types.hex_encode_string raw_txn in diff --git a/etherlink/bin_node/lib_dev/validate.ml b/etherlink/bin_node/lib_dev/validate.ml index 2f9e07d65fc6..9bbe4475e87d 100644 --- a/etherlink/bin_node/lib_dev/validate.ml +++ b/etherlink/bin_node/lib_dev/validate.ml @@ -28,14 +28,10 @@ let validate_chain_id (module Backend_rpc : Services_backend_sig.S) if Z.equal transaction_chain_id chain_id then return (Ok ()) else return (Error "Invalid chain id") -let validate_nonce (module Backend_rpc : Services_backend_sig.S) - (transaction : Transaction.transaction) caller = +let validate_nonce ~next_nonce:(Qty next_nonce) + (transaction : Transaction.transaction) = let open Lwt_result_syntax in - let* nonce = - Backend_rpc.nonce caller Block_parameter.(Block_parameter Latest) - in - let nonce = match nonce with None -> Z.zero | Some (Qty nonce) -> nonce in - if transaction.nonce >= nonce then return (Ok ()) + 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) @@ -109,10 +105,10 @@ let validate_total_cost (tx_object : legacy_transaction_object) ~balance = if total_cost > balance then return (Error "Not enough funds") else return (Ok ()) -let validate_stateless backend_rpc transaction ~caller = +let validate_stateless ~next_nonce backend_rpc transaction ~caller = let open Lwt_result_syntax in let** () = validate_chain_id backend_rpc transaction in - let** () = validate_nonce backend_rpc transaction caller in + let** () = validate_nonce ~next_nonce transaction in let** () = validate_sender_not_a_contract backend_rpc caller in return (Ok ()) @@ -136,17 +132,27 @@ let valid_transaction_object ~backend_rpc ~decode ~hash ~mode tx_raw = let tx_raw = Bytes.unsafe_of_string tx_raw in let**? tx = decode tx_raw in let**? tx_object = Transaction.to_transaction_object ~hash tx in + let* next_nonce = + let (module Backend_rpc : Services_backend_sig.S) = backend_rpc in + Backend_rpc.nonce tx_object.from Block_parameter.(Block_parameter Latest) + in + let next_nonce = + match next_nonce with None -> Qty Z.zero | Some next_nonce -> next_nonce + in let** () = match mode with - | Stateless -> validate_stateless backend_rpc tx ~caller:tx_object.from + | Stateless -> + validate_stateless backend_rpc ~next_nonce tx ~caller:tx_object.from | With_state -> validate_with_state backend_rpc tx tx_object | Full -> - let** () = validate_stateless backend_rpc tx ~caller:tx_object.from in + let** () = + validate_stateless ~next_nonce backend_rpc tx ~caller:tx_object.from + in let** () = validate_with_state backend_rpc tx tx_object in return (Ok ()) in - return (Ok tx_object) + return (Ok (next_nonce, tx_object)) let is_tx_valid ((module Backend_rpc : Services_backend_sig.S) as backend_rpc) ~mode tx_raw = diff --git a/etherlink/bin_node/lib_dev/validate.mli b/etherlink/bin_node/lib_dev/validate.mli index 4e4037df8d7d..587e5052e8d8 100644 --- a/etherlink/bin_node/lib_dev/validate.mli +++ b/etherlink/bin_node/lib_dev/validate.mli @@ -13,9 +13,14 @@ type validation_mode = | Full (** Combination of both *) (** [is_tx_valid backend_rpc tx_raw] validates the transaction - [tx_raw]. *) + [tx_raw] and returns the next allowed nonce for the sender of the + transaction alongside the transaction object. *) val is_tx_valid : (module Services_backend_sig.S) -> mode:validation_mode -> string -> - (Ethereum_types.legacy_transaction_object, string) result tzresult Lwt.t + ( Ethereum_types.quantity * Ethereum_types.legacy_transaction_object, + string ) + result + tzresult + Lwt.t -- GitLab From 3076aad42b1d12343a007a27dff8efd41ee04f78 Mon Sep 17 00:00:00 2001 From: Sylvain Ribstein Date: Thu, 20 Feb 2025 14:41:35 +0100 Subject: [PATCH 2/4] evm/node: add bitset_nonce in tx_queue --- etherlink/bin_node/lib_dev/tx_queue.ml | 213 ++++++++++++++ etherlink/bin_node/lib_dev/tx_queue.mli | 40 +++ etherlink/bin_node/test/dune | 12 +- etherlink/bin_node/test/test_bitset_nonce.ml | 277 +++++++++++++++++++ manifest/product_etherlink.ml | 1 + src/lib_base/bitset.ml | 5 + src/lib_base/bitset.mli | 11 + 7 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 etherlink/bin_node/test/test_bitset_nonce.ml diff --git a/etherlink/bin_node/lib_dev/tx_queue.ml b/etherlink/bin_node/lib_dev/tx_queue.ml index 19b2aa4f9e18..d2c346604a61 100644 --- a/etherlink/bin_node/lib_dev/tx_queue.ml +++ b/etherlink/bin_node/lib_dev/tx_queue.ml @@ -47,6 +47,214 @@ type request = { callback : callback; } +(** [Nonce_bitset] registers known nonces from transactions that went + through the tx_queue from a specific sender address. With this + structure it's easy to do bookkeeping of address' nonce without + going through all the transactions of the queue. + + The invariants are that for any nonce_bitset [nb]: + + - When creating [nb], [nb.next_nonce] is the next valid nonce for + the state. + + - When adding a nonce [n] to [nb], [n] must be superior or equal + to [nb.next_nonce]. This is enforced by the validation in + {!Validate.is_tx_valid}. + + - [nb.next_nonce] can only increase over time. This is enforced by + [shift] and [offset]. + *) +module Nonce_bitset = struct + module Bitset = Tezos_base.Bitset + + (** [t] allows to register for a given address all nonces that are + currently used by transaction in the tx_queue. *) + type t = { + next_nonce : Z.t; + (** [next_nonce] is the base value for any position found in + {!field:bitset}. It’s set to be the next expected nonce + for a given address, which is the nonce found in the + backend. *) + bitset : Bitset.t; + } + + (** [create ~next_nonce] creates a {!t} struct with empty [bitset]. *) + let create ~next_nonce = {next_nonce; bitset = Bitset.empty} + + (** [offset ~nonce1 ~nonce2] computes the difference between + [nonce1] and [nonce2]. + + Fails if [nonce2 > nonce1] or if the difference between the two is + more than {!Int.max_int}. *) + let offset ~nonce1 ~nonce2 = + let open Result_syntax in + if Z.gt nonce2 nonce1 then + error_with + "Internal: invalid nonce diff. nonce2 %a must be inferior or equal to \ + nonce1 %a." + Z.pp_print + nonce2 + Z.pp_print + nonce1 + else + let offset = Z.(nonce1 - nonce2) in + if Z.fits_int offset then return (Z.to_int offset) + else + error_with + "Internal: invalid nonce offset, it's too large to fit in an integer." + + (** [add bitset_nonce ~nonce] adds the nonce [nonce] to [bitset_nonce]. *) + let add {next_nonce; bitset} ~nonce = + let open Result_syntax in + let* offset_position = offset ~nonce1:nonce ~nonce2:next_nonce in + let* bitset = Bitset.add bitset offset_position in + return {next_nonce; bitset} + + (** [remove bitset_nonce ~nonce] removes the nonce [nonce] from + [bitset_nonce]. + + If [nonce] is strictly inferior to [bitset_nonce.next_nonce] then + it's a no-op because nonce can't exist in the bitset. *) + let remove {next_nonce; bitset} ~nonce = + let open Result_syntax in + if Z.lt nonce next_nonce then + (* no need to remove a nonce that can't exist in the bitset *) + return {next_nonce; bitset} + else + let* offset_position = offset ~nonce1:nonce ~nonce2:next_nonce in + let* bitset = Bitset.remove bitset offset_position in + return {next_nonce; bitset} + + (** [shift bitset_nonce ~nonce] shifts the bitset of [bitset_nonce] + so the next_nonce is now [nonce]. Shifting the bitset means + that nonces that are inferior to [nonce] are dropped. + + Fails if [nonce] is strictly inferior to + [bitset_nonce.next_nonce]. *) + let shift {next_nonce; bitset} ~nonce = + let open Result_syntax in + let* offset = offset ~nonce1:nonce ~nonce2:next_nonce in + let* bitset = Bitset.shift_right bitset ~offset in + return {next_nonce = nonce; bitset} + + (** [is_empty bitset_nonce] checks if the bitset is empty, i.e. no + position is at 1. *) + let is_empty {bitset; _} = Bitset.is_empty bitset + + (** [next_gap bitset_nonce] returns the next available nonce. *) + let next_gap {next_nonce; bitset} = + let offset_position = Z.(trailing_zeros @@ lognot @@ Bitset.to_z bitset) in + Z.(next_nonce + of_int offset_position) + + (** [shift_then_next_gap bitset_nonce ~shift_nonce] calls {!shift + ~nonce:shift_nonce} then {!next_gap bitset_nonce}. *) + let shift_then_next_gap bitset_nonce ~shift_nonce = + let open Result_syntax in + let* bitset_nonce = shift bitset_nonce ~nonce:shift_nonce in + return @@ next_gap bitset_nonce +end + +module Address_nonce = struct + module S = String.Hashtbl + + (** [t] contains the nonces of transactions from the tx_queue. If an + address has no transactions in the tx_queue, it will have no + value here. In other words, the bitset for that address is + removed when the last transaction from an address is either + confirmed or dropped. *) + type t = Nonce_bitset.t S.t + + let empty ~start_size = S.create start_size + + let find nonces ~addr = S.find nonces addr + + let update nonces addr nonce_bitset = + if Nonce_bitset.is_empty nonce_bitset then S.remove nonces addr + else S.replace nonces addr nonce_bitset + + let add nonces ~addr ~next_nonce ~nonce = + let open Result_syntax in + let nonce_bitset = S.find nonces addr in + let* nonce_bitset = + match nonce_bitset with + | Some nonce_bitset -> + (* Only shifts if the next_nonce we want to confirm is + superior of equal to current next nonce. + + Checking here prevents a possible race condition where a + transaction is submitted a second time and the + confirmation of the first try is received while the + validation of that transaction is processed. In that case + we could add a transaction in the tx_queue that is + already confirmed. In such rare case the [bitset_nonce] + would have a [next_nonce] already superior to the given + one. + + If [nonce_bitset.next_nonce > next_nonce] then there is no + need to shift because [next_nonce] is already in the past. *) + if Z.gt nonce_bitset.Nonce_bitset.next_nonce next_nonce then + return nonce_bitset + else Nonce_bitset.shift nonce_bitset ~nonce:next_nonce + | None -> return @@ Nonce_bitset.create ~next_nonce + in + let* nonce_bitset = + (* Only adds [nonce] if [bitset_nonce.next_nonce] is inferior or + equal. If [nonce_bitset.next_nonce > nonce] then there is no + need to add because [nonce] is already in the past. + + This is follow-up to the previous comment where we are in a + rare ce condition of the transaction is being validated while + a transaction with a superior nonce is being confirmed. In + such case we simply don't register the nonce, and the + transaction will be dropped by the upstream node when + receiving it. *) + if Z.gt nonce_bitset.Nonce_bitset.next_nonce nonce then + return nonce_bitset + else Nonce_bitset.add nonce_bitset ~nonce + in + let () = S.replace nonces addr nonce_bitset in + return_unit + + let confirm_nonce nonces ~addr ~nonce = + let open Result_syntax in + let nonce_bitset = S.find nonces addr in + match nonce_bitset with + | Some nonce_bitset -> + let next_nonce = Z.succ nonce in + if Z.gt nonce_bitset.Nonce_bitset.next_nonce next_nonce then + (* A tx with a superior nonce was already confirmed, nothing + to confirm. + + This a an unexpected case but if it occurs it's not a + problem and the tx_queue is not corrupted. *) + return_unit + else + let* nonce_bitset = + Nonce_bitset.shift nonce_bitset ~nonce:next_nonce + in + update nonces addr nonce_bitset ; + return_unit + | None -> return_unit + + let remove nonces ~addr ~nonce = + let open Result_syntax in + let nonce_bitset = S.find nonces addr in + match nonce_bitset with + | Some nonce_bitset -> + let* nonce_bitset = Nonce_bitset.remove nonce_bitset ~nonce in + update nonces addr nonce_bitset ; + return_unit + | None -> return_unit + + let next_gap nonces ~addr ~next_nonce = + let open Result_syntax in + let nonce_bitset = S.find nonces addr in + match nonce_bitset with + | Some nonce_bitset -> + Nonce_bitset.shift_then_next_gap nonce_bitset ~shift_nonce:next_nonce + | None -> return next_nonce +end + module Tx_object = struct open Ethereum_types module S = String.Hashtbl @@ -488,3 +696,8 @@ let shutdown () = let*! () = Tx_queue_events.shutdown () in let*! () = Worker.shutdown w in return_unit + +module Internal_for_tests = struct + module Nonce_bitset = Nonce_bitset + module Address_nonce = Address_nonce +end diff --git a/etherlink/bin_node/lib_dev/tx_queue.mli b/etherlink/bin_node/lib_dev/tx_queue.mli index 14b7f2d20bf0..0b351ecc6c89 100644 --- a/etherlink/bin_node/lib_dev/tx_queue.mli +++ b/etherlink/bin_node/lib_dev/tx_queue.mli @@ -64,3 +64,43 @@ val beacon : tick_interval:float -> unit tzresult Lwt.t val find : Ethereum_types.hash -> Ethereum_types.legacy_transaction_object option tzresult Lwt.t + +(**/*) + +module Internal_for_tests : sig + module Nonce_bitset : sig + type t = {next_nonce : Z.t; bitset : Tezos_base.Bitset.t} + + val create : next_nonce:Z.t -> t + + val offset : nonce1:Z.t -> nonce2:Z.t -> int tzresult + + val add : t -> nonce:Z.t -> t tzresult + + val remove : t -> nonce:Z.t -> t tzresult + + val shift : t -> nonce:Z.t -> t tzresult + + val is_empty : t -> bool + + val next_gap : t -> Z.t + + val shift_then_next_gap : t -> shift_nonce:Z.t -> Z.t tzresult + end + + module Address_nonce : sig + type t + + val empty : start_size:int -> t + + val add : t -> addr:string -> next_nonce:Z.t -> nonce:Z.t -> unit tzresult + + val find : t -> addr:string -> Nonce_bitset.t option + + val confirm_nonce : t -> addr:string -> nonce:Z.t -> unit tzresult + + val remove : t -> addr:string -> nonce:Z.t -> unit tzresult + + val next_gap : t -> addr:string -> next_nonce:Z.t -> Z.t tzresult + end +end diff --git a/etherlink/bin_node/test/dune b/etherlink/bin_node/test/dune index 4870aa95d578..a1415246b8b8 100644 --- a/etherlink/bin_node/test/dune +++ b/etherlink/bin_node/test/dune @@ -7,7 +7,8 @@ test_ethbloom test_call_tracer_algo test_wasm_runtime - test_blueprint_roundtrip) + test_blueprint_roundtrip + test_bitset_nonce) (libraries bls12-381.archive octez-evm-node-libs.evm_node_rust_deps @@ -89,3 +90,12 @@ invalidRLPTest.json ../../kernel_evm/kernel/tests/resources/mainnet_evm_kernel.wasm) (action (run %{dep:./test_blueprint_roundtrip.exe}))) + +(rule + (alias runtest) + (package octez-evm-node-tests) + (deps + rlptest.json + invalidRLPTest.json + ../../kernel_evm/kernel/tests/resources/mainnet_evm_kernel.wasm) + (action (run %{dep:./test_bitset_nonce.exe}))) diff --git a/etherlink/bin_node/test/test_bitset_nonce.ml b/etherlink/bin_node/test/test_bitset_nonce.ml new file mode 100644 index 000000000000..20377251ffe5 --- /dev/null +++ b/etherlink/bin_node/test/test_bitset_nonce.ml @@ -0,0 +1,277 @@ +(*****************************************************************************) +(* *) +(* SPDX-License-Identifier: MIT *) +(* Copyright (c) 2025 Functori *) +(* Copyright (c) 2025 Nomadic Labs *) +(* *) +(*****************************************************************************) + +(** Testing + ------- + Component: Bin_evm_node + Invocation: dune exec etherlink/bin_node/test/test_bitset_nonce.exe + Subject: Tests for Nonce_bitset +*) +open Evm_node_lib_dev.Tx_queue.Internal_for_tests + +let comparable_bitset = + Check.( + convert + (fun ({next_nonce; bitset} : Nonce_bitset.t) -> + (Z.to_int next_nonce, Tezos_base.Bitset.to_list bitset)) + (tuple2 int (list int))) + +let test_register = + Test.register + ~uses_node:false + ~uses_client:false + ~uses_admin_client:false + ~__FILE__ + +let check_nonce ~__LOC__ ~found ~expected = + let open Check in + (Z.to_int found = Z.to_int expected) + int + ~__LOC__ + ~error_msg:"invalid nonce detected, found %L, expected %R" + +let make_bitset_nonce ~__LOC__ (next_nonce, bitset) = + Nonce_bitset. + { + next_nonce = Z.of_int next_nonce; + bitset = + WithExceptions.Result.get_ok ~loc:__LOC__ + @@ Tezos_base.Bitset.from_list bitset; + } + +let check_bitset ~__LOC__ bitset_nonce ~expected = + let open Check in + let expected_bitset = make_bitset_nonce ~__LOC__ expected in + (bitset_nonce = expected_bitset) + comparable_bitset + ~__LOC__ + ~error_msg:"invalid bitset_nonce detected, found %L, expected %R" + +let check_bitset_opt ~__LOC__ bitset ~expected = + let open Check in + let expected_bitset = Option.map (make_bitset_nonce ~__LOC__) expected in + (bitset = expected_bitset) + (option comparable_bitset) + ~__LOC__ + ~error_msg:"invalid bitset detected, found %L, expected %R" + +let create ~__LOC__ ~next_nonce = + let bitset = Nonce_bitset.create ~next_nonce:(Z.of_int next_nonce) in + check_bitset ~__LOC__ bitset ~expected:(next_nonce, []) ; + bitset + +let add ~__LOC__ bitset ~nonce ~expected = + let bitset = + WithExceptions.Result.get_ok ~loc:__LOC__ + @@ Nonce_bitset.add bitset ~nonce:Z.(of_int nonce) + in + check_bitset ~__LOC__ bitset ~expected ; + bitset + +let shift ~__LOC__ bitset ~nonce ~expected = + let bitset = + WithExceptions.Result.get_ok ~loc:__LOC__ + @@ Nonce_bitset.shift bitset ~nonce:Z.(of_int nonce) + in + check_bitset ~__LOC__ bitset ~expected ; + bitset + +let remove ~__LOC__ bitset ~nonce ~expected = + let bitset = + WithExceptions.Result.get_ok ~loc:__LOC__ + @@ Nonce_bitset.remove bitset ~nonce:Z.(of_int nonce) + in + check_bitset ~__LOC__ bitset ~expected ; + bitset + +let next_gap_nonce ~__LOC__ bitset ~expected = + let found = Nonce_bitset.next_gap bitset in + check_nonce ~__LOC__ ~found ~expected:(Z.of_int expected) + +let shift_then_next_gap_nonce ~__LOC__ ~shift_nonce bitset ~expected = + let found = + WithExceptions.Result.get_ok ~loc:__LOC__ + @@ Nonce_bitset.shift_then_next_gap + ~shift_nonce:(Z.of_int shift_nonce) + bitset + in + check_nonce ~__LOC__ ~found ~expected:(Z.of_int expected) + +let add_shift_then_remove = + test_register ~title:"Add, shift and remove" ~tags:["bitset_nonce"] + @@ fun () -> + let nonce = 0 in + let bitset = create ~__LOC__ ~next_nonce:nonce in + let bitset = add ~__LOC__ bitset ~nonce:0 ~expected:(nonce, [0]) in + let bitset = add ~__LOC__ bitset ~nonce:1 ~expected:(nonce, [0; 1]) in + let bitset = add ~__LOC__ bitset ~nonce:2 ~expected:(nonce, [0; 1; 2]) in + let bitset = add ~__LOC__ bitset ~nonce:3 ~expected:(nonce, [0; 1; 2; 3]) in + let nonce = 1 in + let bitset = shift ~__LOC__ bitset ~nonce ~expected:(nonce, [0; 1; 2]) in + + let nonce = 2 in + let bitset = shift ~__LOC__ bitset ~nonce ~expected:(nonce, [0; 1]) in + + let nonce = 3 in + let bitset = shift ~__LOC__ bitset ~nonce ~expected:(nonce, [0]) in + + let nonce = 4 in + let bitset = shift ~__LOC__ bitset ~nonce ~expected:(nonce, []) in + + let bitset = add ~__LOC__ bitset ~nonce:4 ~expected:(nonce, [0]) in + let bitset = add ~__LOC__ bitset ~nonce:5 ~expected:(nonce, [0; 1]) in + let bitset = add ~__LOC__ bitset ~nonce:6 ~expected:(nonce, [0; 1; 2]) in + let bitset = add ~__LOC__ bitset ~nonce:7 ~expected:(nonce, [0; 1; 2; 3]) in + let bitset = remove ~__LOC__ bitset ~nonce:4 ~expected:(nonce, [1; 2; 3]) in + let bitset = remove ~__LOC__ bitset ~nonce:5 ~expected:(nonce, [2; 3]) in + let bitset = remove ~__LOC__ bitset ~nonce:6 ~expected:(nonce, [3]) in + let bitset = remove ~__LOC__ bitset ~nonce:7 ~expected:(nonce, []) in + + let* () = + Log.info "cardinal %d" (Tezos_base.Bitset.cardinal bitset.bitset) ; + if not (Nonce_bitset.is_empty bitset) then Test.fail "bitset is not empty" + else unit + in + unit + +let test_next_gap_nonce = + test_register + ~title:"next gap nonce is correctly computed" + ~tags:["bitset_nonce"; "next_gap"] + @@ fun () -> + let nonce = 0 in + let bitset = create ~__LOC__ ~next_nonce:nonce in + next_gap_nonce ~__LOC__ bitset ~expected:0 ; + shift_then_next_gap_nonce ~__LOC__ bitset ~shift_nonce:nonce ~expected:0 ; + shift_then_next_gap_nonce ~__LOC__ bitset ~shift_nonce:(nonce + 1) ~expected:1 ; + + let bitset = add ~__LOC__ bitset ~nonce:0 ~expected:(nonce, [0]) in + next_gap_nonce ~__LOC__ bitset ~expected:1 ; + shift_then_next_gap_nonce ~__LOC__ bitset ~shift_nonce:nonce ~expected:1 ; + shift_then_next_gap_nonce ~__LOC__ bitset ~shift_nonce:(nonce + 1) ~expected:1 ; + shift_then_next_gap_nonce ~__LOC__ bitset ~shift_nonce:(nonce + 2) ~expected:2 ; + + let bitset = add ~__LOC__ bitset ~nonce:1 ~expected:(nonce, [0; 1]) in + next_gap_nonce ~__LOC__ bitset ~expected:2 ; + shift_then_next_gap_nonce ~__LOC__ bitset ~shift_nonce:nonce ~expected:2 ; + shift_then_next_gap_nonce ~__LOC__ bitset ~shift_nonce:(nonce + 3) ~expected:3 ; + + let bitset = add ~__LOC__ bitset ~nonce:3 ~expected:(nonce, [0; 1; 3]) in + next_gap_nonce ~__LOC__ bitset ~expected:2 ; + shift_then_next_gap_nonce ~__LOC__ bitset ~shift_nonce:(nonce + 3) ~expected:4 ; + + let bitset = add ~__LOC__ bitset ~nonce:2 ~expected:(nonce, [0; 1; 2; 3]) in + next_gap_nonce ~__LOC__ bitset ~expected:4 ; + shift_then_next_gap_nonce ~__LOC__ bitset ~shift_nonce:nonce ~expected:4 ; + + let bitset = remove ~__LOC__ bitset ~nonce:1 ~expected:(nonce, [0; 2; 3]) in + next_gap_nonce ~__LOC__ bitset ~expected:1 ; + shift_then_next_gap_nonce ~__LOC__ bitset ~shift_nonce:nonce ~expected:1 ; + shift_then_next_gap_nonce ~__LOC__ bitset ~shift_nonce:(nonce + 1) ~expected:1 ; + shift_then_next_gap_nonce ~__LOC__ bitset ~shift_nonce:(nonce + 2) ~expected:4 ; + + let bitset = remove ~__LOC__ bitset ~nonce:0 ~expected:(nonce, [2; 3]) in + next_gap_nonce ~__LOC__ bitset ~expected:0 ; + shift_then_next_gap_nonce ~__LOC__ bitset ~shift_nonce:nonce ~expected:0 ; + shift_then_next_gap_nonce ~__LOC__ bitset ~shift_nonce:(nonce + 1) ~expected:1 ; + shift_then_next_gap_nonce ~__LOC__ bitset ~shift_nonce:(nonce + 2) ~expected:4 ; + unit + +module Address_nonce_helpers = struct + let find_bitset_nonce nonces ~addr = Address_nonce.find nonces ~addr + + let get_bitset_nonce nonces ~addr ~__LOC__ = + WithExceptions.Option.get ~loc:__LOC__ @@ find_bitset_nonce nonces ~addr + + let add_nonce nonces ~addr ~__LOC__ ~next_nonce ~nonce = + WithExceptions.Result.get_ok ~loc:__LOC__ + @@ Address_nonce.add + nonces + ~addr + ~next_nonce:(Z.of_int next_nonce) + ~nonce:(Z.of_int nonce) + + let next_gap_nonce nonces ~addr ~__LOC__ ~next_nonce = + WithExceptions.Result.get_ok ~loc:__LOC__ + @@ Address_nonce.next_gap nonces ~addr ~next_nonce:(Z.of_int next_nonce) + + let confirm_nonce nonces ~addr ~__LOC__ ~nonce = + WithExceptions.Result.get_ok ~loc:__LOC__ + @@ Address_nonce.confirm_nonce nonces ~addr ~nonce:(Z.of_int nonce) + + let remove_nonce nonces ~addr ~__LOC__ ~nonce = + WithExceptions.Result.get_ok ~loc:__LOC__ + @@ Address_nonce.remove nonces ~addr ~nonce:(Z.of_int nonce) + + let add nonces ~addr ~__LOC__ ~next_nonce ~nonce ~expected = + add_nonce nonces ~addr ~__LOC__ ~next_nonce ~nonce ; + let bitset = get_bitset_nonce nonces ~addr ~__LOC__ in + check_bitset ~__LOC__ bitset ~expected + + let confirm nonces ~addr ~__LOC__ ~nonce ~expected = + confirm_nonce nonces ~addr ~__LOC__ ~nonce ; + let bitset = find_bitset_nonce nonces ~addr in + check_bitset_opt ~__LOC__ bitset ~expected + + let remove nonces ~addr ~__LOC__ ~nonce ~expected = + remove_nonce nonces ~addr ~__LOC__ ~nonce ; + let bitset = find_bitset_nonce nonces ~addr in + check_bitset_opt ~__LOC__ bitset ~expected + + let next_gap nonces ~addr ~__LOC__ ~next_nonce ~expected = + let found = next_gap_nonce nonces ~addr ~__LOC__ ~next_nonce in + check_nonce ~__LOC__ ~found ~expected:(Z.of_int expected) +end + +let test_address_nonces = + test_register + ~title: + "Add multiple nonces to an address in the Address_nonces hashtbl then \ + confirm or delete them" + ~tags:["bitset_nonce"; "address_nonce"] + @@ fun () -> + let nonces = Address_nonce.empty ~start_size:20 in + let addr = "bootstrap" in + let next_gap = Address_nonce_helpers.next_gap nonces ~addr in + let add = Address_nonce_helpers.add nonces ~addr in + let confirm = Address_nonce_helpers.confirm nonces ~addr in + let remove = Address_nonce_helpers.remove nonces ~addr in + + let next_nonce = 0 in + next_gap ~__LOC__ ~next_nonce ~expected:0 ; + add ~__LOC__ ~next_nonce ~nonce:0 ~expected:(next_nonce, [0]) ; + + next_gap ~__LOC__ ~next_nonce:10 ~expected:10 ; + add ~__LOC__ ~next_nonce ~nonce:1 ~expected:(next_nonce, [0; 1]) ; + add ~__LOC__ ~next_nonce ~nonce:2 ~expected:(next_nonce, [0; 1; 2]) ; + add ~__LOC__ ~next_nonce ~nonce:3 ~expected:(next_nonce, [0; 1; 2; 3]) ; + confirm + ~__LOC__ + ~nonce:next_nonce + ~expected:(Some (next_nonce + 1, [0; 1; 2])) ; + + let next_nonce = 1 in + confirm ~__LOC__ ~nonce:next_nonce ~expected:(Some (next_nonce + 1, [0; 1])) ; + + let next_nonce = 2 in + confirm ~__LOC__ ~nonce:next_nonce ~expected:(Some (next_nonce + 1, [0])) ; + + let next_nonce = 3 in + confirm ~__LOC__ ~nonce:next_nonce ~expected:None ; + add ~__LOC__ ~next_nonce ~nonce:4 ~expected:(next_nonce, [1]) ; + add ~__LOC__ ~next_nonce ~nonce:next_nonce ~expected:(next_nonce, [0; 1]) ; + remove ~__LOC__ ~nonce:next_nonce ~expected:(Some (next_nonce, [1])) ; + remove ~__LOC__ ~nonce:4 ~expected:None ; + unit + +let () = + add_shift_then_remove ; + test_next_gap_nonce ; + test_address_nonces + +let () = Test.run () diff --git a/manifest/product_etherlink.ml b/manifest/product_etherlink.ml index 0b07df82eb56..ccd22abf9f9c 100644 --- a/manifest/product_etherlink.ml +++ b/manifest/product_etherlink.ml @@ -313,6 +313,7 @@ let _octez_evm_node_tests = "test_call_tracer_algo"; "test_wasm_runtime"; "test_blueprint_roundtrip"; + "test_bitset_nonce"; ] ~path:"etherlink/bin_node/test" ~opam:"octez-evm-node-tests" diff --git a/src/lib_base/bitset.ml b/src/lib_base/bitset.ml index 2f4e179682fb..97e76f8a38a1 100644 --- a/src/lib_base/bitset.ml +++ b/src/lib_base/bitset.ml @@ -55,6 +55,11 @@ let remove field pos = let* () = error_when Compare.Int.(pos < 0) (Invalid_position pos) in return @@ Z.logand field Z.(lognot (shift_left one pos)) +let shift_right field ~offset = + let open Result_syntax in + let* () = error_when Compare.Int.(offset < 0) (Invalid_input "shift_right") in + return @@ Z.shift_right field offset + let from_list positions = List.fold_left_e add empty positions let to_list field = diff --git a/src/lib_base/bitset.mli b/src/lib_base/bitset.mli index 26e6247ef556..b3b45a843bb6 100644 --- a/src/lib_base/bitset.mli +++ b/src/lib_base/bitset.mli @@ -40,6 +40,17 @@ val add : t -> int -> t tzresult This functions returns [Invalid_position i] if [i] is negative. *) val remove : t -> int -> t tzresult +(** [shift_right bitset ~offset] returns a new bitset [bitset'] such + that for any [i] we have: + + [mem (i - offset) bitset'] if and only if [i >= offset] and [mem i bitset]. + + In other words, positions smaller than `offset` are removed and positions larger + or equal than `offset` are decreased by `offset`. + + This functions returns [Invalid_input "shift_right"] if [offset] is negative. *) +val shift_right : t -> offset:int -> t tzresult + (** [from_list positions] folds [add] over the [positions] starting from [empty]. This function returns [Invalid_position i] if [i] is negative and appears in [positions]. *) -- GitLab From 809eb87195cbc96d64b22ba0ede979719fc3a841 Mon Sep 17 00:00:00 2001 From: Sylvain Ribstein Date: Fri, 28 Feb 2025 23:29:03 +0100 Subject: [PATCH 3/4] evm/node: tx_queue can return first gap nonce available --- etherlink/CHANGES_NODE.md | 6 + etherlink/bin_node/lib_dev/services.ml | 25 +- etherlink/bin_node/lib_dev/tx_queue.ml | 108 +++++++- etherlink/bin_node/lib_dev/tx_queue.mli | 29 ++- etherlink/bin_node/lib_dev/tx_queue_events.ml | 11 + .../bin_node/lib_dev/tx_queue_events.mli | 3 + etherlink/tezt/tests/evm_sequencer.ml | 238 +++++++++++++++++- .../EVM node- list events regression.out | 16 ++ 8 files changed, 415 insertions(+), 21 deletions(-) diff --git a/etherlink/CHANGES_NODE.md b/etherlink/CHANGES_NODE.md index e486735f5d15..a822453ea5af 100644 --- a/etherlink/CHANGES_NODE.md +++ b/etherlink/CHANGES_NODE.md @@ -21,6 +21,12 @@ features. They can be modified or removed without any deprecation notices. If you start using them, you probably want to use `octez-evm-node check config --config-file PATH` to assert your configuration file is still valid.* +- With the `tx_queue` feature enabled in an observer node, for the RPC + `eth_getTransactionCount` at the `pending` block, it will return the + next available nonce found in the `tx_queue`. It also works for + transactions that have been already forwarded to the upstream node + but not yet confirmed. (!!16829) + ## Version 0.19 (2025-03-10) This release contains a number of quality of life improvements, notably related diff --git a/etherlink/bin_node/lib_dev/services.ml b/etherlink/bin_node/lib_dev/services.ml index f8637dfd1136..8dac9122fc85 100644 --- a/etherlink/bin_node/lib_dev/services.ml +++ b/etherlink/bin_node/lib_dev/services.ml @@ -550,7 +550,18 @@ let dispatch_request (rpc : Configuration.rpc) let f (address, block_param) = match block_param with | Ethereum_types.Block_parameter.(Block_parameter Pending) -> - let* nonce = Tx_pool.nonce address in + let* nonce = + if + Option.is_some + config.experimental_features.enable_tx_queue + then + let* next_nonce = Backend_rpc.nonce address block_param in + let next_nonce = + Option.value ~default:Qty.zero next_nonce + in + Tx_queue.nonce ~next_nonce address + else Tx_pool.nonce address + in rpc_ok nonce | _ -> let* nonce = Backend_rpc.nonce address block_param in @@ -678,10 +689,12 @@ let dispatch_request (rpc : Configuration.rpc) Tx_pool_events.invalid_transaction ~transaction:tx_raw in rpc_error (Rpc_errors.transaction_rejected err None) - | Ok (_next_nonce, transaction_object) -> ( + | Ok (next_nonce, transaction_object) -> ( let* tx_hash = if Configuration.is_tx_queue_enabled config then - let* () = Tx_queue.inject transaction_object tx_raw in + let* () = + Tx_queue.inject ~next_nonce transaction_object tx_raw + in return (Ok transaction_object.hash) else Tx_pool.add transaction_object txn in @@ -916,11 +929,13 @@ let dispatch_private_request (rpc : Configuration.rpc) let transaction = Ethereum_types.hex_encode_string raw_txn in let*! () = Tx_pool_events.invalid_transaction ~transaction in rpc_error (Rpc_errors.transaction_rejected err None) - | Ok (_next_nonce, transaction_object) -> ( + | Ok (next_nonce, transaction_object) -> ( let* tx_hash = if Configuration.is_tx_queue_enabled config then let transaction = Ethereum_types.hex_encode_string raw_txn in - let* () = Tx_queue.inject transaction_object transaction in + let* () = + Tx_queue.inject ~next_nonce transaction_object transaction + in return @@ Ok transaction_object.hash else Tx_pool.add transaction_object raw_txn in diff --git a/etherlink/bin_node/lib_dev/tx_queue.ml b/etherlink/bin_node/lib_dev/tx_queue.ml index d2c346604a61..4f6f3bd91d18 100644 --- a/etherlink/bin_node/lib_dev/tx_queue.ml +++ b/etherlink/bin_node/lib_dev/tx_queue.ml @@ -42,6 +42,7 @@ type pending_request = { type callback = all_variant variant_callback type request = { + next_nonce : Ethereum_types.quantity; payload : Ethereum_types.hex; tx_object : Ethereum_types.legacy_transaction_object; callback : callback; @@ -313,6 +314,7 @@ type state = { mutable queue : queue_request Queue.t; pending : Pending_transactions.t; tx_object : Tx_object.t; + address_nonce : Address_nonce.t; config : Configuration.tx_queue; keep_alive : bool; } @@ -343,6 +345,11 @@ module Request = struct txn_hash : Ethereum_types.hash; } -> (Ethereum_types.legacy_transaction_object option, tztrace) t + | Nonce : { + next_nonce : Ethereum_types.quantity; + address : Ethereum_types.address; + } + -> (Ethereum_types.quantity, tztrace) t | Tick : (unit, tztrace) t | Clear : (unit, tztrace) t @@ -393,6 +400,18 @@ module Request = struct (obj1 (req "request" (constant "clear"))) (function View Clear -> Some () | _ -> None) (fun _ -> assert false); + case + Json_only + ~title:"Nonce" + (obj3 + (req "request" (constant "nonce")) + (req "next_nonce" Ethereum_types.quantity_encoding) + (req "address" Ethereum_types.address_encoding)) + (function + | View (Nonce {next_nonce; address}) -> + Some ((), next_nonce, address) + | _ -> None) + (fun _ -> assert false); ] let pp fmt (View r) = @@ -404,6 +423,8 @@ module Request = struct | Find {txn_hash = Hash (Hex txn_hash)} -> fprintf fmt "Find %s" txn_hash | Tick -> fprintf fmt "Tick" | Clear -> fprintf fmt "Clear" + | Nonce {next_nonce = _; address = Address (Hex address)} -> + fprintf fmt "Nonce %s" address end module Worker = Worker.MakeSingle (Name) (Request) (Types) @@ -491,27 +512,72 @@ module Handlers = struct let open Lwt_result_syntax in let state = Worker.state self in match request with - | Inject {payload; tx_object; callback} -> + | Inject {next_nonce; payload; tx_object; callback} -> + let (Address (Hex addr)) = tx_object.from in + let (Qty tx_nonce) = tx_object.nonce in let pending_callback (reason : pending_variant) = - let*! () = + let open Lwt_syntax in + let* res = match reason with - | `Dropped -> Tx_queue_events.transaction_dropped tx_object.hash - | `Confirmed -> Tx_queue_events.transaction_confirmed tx_object.hash + | `Dropped -> + let* () = Tx_queue_events.transaction_dropped tx_object.hash in + return + @@ Address_nonce.remove + state.address_nonce + ~addr + ~nonce:tx_nonce + | `Confirmed -> + let* () = + Tx_queue_events.transaction_confirmed tx_object.hash + in + return + @@ Address_nonce.confirm_nonce + state.address_nonce + ~addr + ~nonce:tx_nonce + in + let* () = + match res with + | Ok () -> return_unit + | Error errs -> Tx_queue_events.callback_error errs in Tx_object.remove state.tx_object tx_object.hash ; callback (reason :> all_variant) in let queue_callback reason = - (match reason with - | `Accepted -> - Pending_transactions.add - state.pending - tx_object.hash - pending_callback - | `Refused -> Tx_object.remove state.tx_object tx_object.hash) ; + let open Lwt_syntax in + let* res = + match reason with + | `Accepted -> + Pending_transactions.add + state.pending + tx_object.hash + pending_callback ; + return_ok_unit + | `Refused -> + Tx_object.remove state.tx_object tx_object.hash ; + return + @@ Address_nonce.remove + state.address_nonce + ~addr + ~nonce:tx_nonce + in + let* () = + match res with + | Ok () -> return_unit + | Error errs -> Tx_queue_events.callback_error errs + in callback (reason :> all_variant) in Tx_object.add state.tx_object tx_object ; + let Ethereum_types.(Qty next_nonce) = next_nonce in + let*? () = + Address_nonce.add + state.address_nonce + ~addr + ~next_nonce + ~nonce:tx_nonce + in Queue.add {payload; queue_callback} state.queue ; return_unit | Confirm {txn_hash} -> ( @@ -570,6 +636,12 @@ module Handlers = struct Queue.clear state.queue ; let*! () = Tx_queue_events.cleared () in return_unit + | Nonce {next_nonce; address = Address (Hex addr)} -> + let Ethereum_types.(Qty next_nonce) = next_nonce in + let*? next_gap = + Address_nonce.next_gap state.address_nonce ~addr ~next_nonce + in + return @@ Ethereum_types.Qty next_gap type launch_error = tztrace @@ -584,6 +656,10 @@ module Handlers = struct (* start with /4 and let it grow if necessary to not allocate too much at start. *) tx_object = Tx_object.empty ~start_size:(config.max_size / 4); + address_nonce = Address_nonce.empty ~start_size:(config.max_size / 10); + (* start with /10 and let it grow if necessary to not allocate + too much at start. It's expected to have less different + addresses than transactions. *) config; keep_alive; } @@ -657,12 +733,12 @@ let rec beacon ~tick_interval = let*! () = Lwt_unix.sleep tick_interval in beacon ~tick_interval -let inject ?(callback = fun _ -> Lwt_syntax.return_unit) +let inject ?(callback = fun _ -> Lwt_syntax.return_unit) ~next_nonce (tx_object : Ethereum_types.legacy_transaction_object) txn = let open Lwt_syntax in let* () = Tx_queue_events.add_transaction tx_object.hash in let* worker = worker_promise in - push_request worker (Inject {payload = txn; tx_object; callback}) + push_request worker (Inject {next_nonce; payload = txn; tx_object; callback}) let confirm txn_hash = bind_worker @@ fun w -> push_request w (Confirm {txn_hash}) @@ -690,6 +766,12 @@ let clear () = let*? w = Lazy.force worker in Worker.Queue.push_request_and_wait w Clear |> handle_request_error +let nonce ~next_nonce address = + let open Lwt_result_syntax in + let*? w = Lazy.force worker in + Worker.Queue.push_request_and_wait w (Nonce {next_nonce; address}) + |> handle_request_error + let shutdown () = let open Lwt_result_syntax in bind_worker @@ fun w -> diff --git a/etherlink/bin_node/lib_dev/tx_queue.mli b/etherlink/bin_node/lib_dev/tx_queue.mli index 0b351ecc6c89..256fe5f73cb4 100644 --- a/etherlink/bin_node/lib_dev/tx_queue.mli +++ b/etherlink/bin_node/lib_dev/tx_queue.mli @@ -41,12 +41,29 @@ val shutdown : unit -> unit tzresult Lwt.t (** [clear ()] removes the tx queue data but keeps the allocated space *) val clear : unit -> unit tzresult Lwt.t -(** [inject ?callback tx_object raw_txn] pushes the raw transaction - [raw_txn] to the worker queue. +(** [inject ?callback ~next_nonce tx_object raw_txn] pushes the + transaction [raw_txn] to the worker queue. + + The [tx_object] is stored until the transaction is confirmed or + dropped, so the transaction can be retrieved with [find]. + + [next_nonce] is the next nonce expected by the kernel for the + address [tx_object.from]. [next_nonce] must always be increasing + for any given [tx_object.from], else if the tx_queue already + contains a transaction for [tx_object.from] it will raise an error + for that request. The increasing order of [next_nonce] is enforced + by the kernel execution where transaction must be executed in + order. + + Any transaction added in the tx_queue via {!inject} must be a + valid transaction. In particular the nonce of the transaction must + be valid, i.e. it must be greater or equal to [next_nonce]. This + is validated by {!Validate.is_tx_valid}. {b Note:} The promise will be sleeping until at least {!start} is called. *) val inject : ?callback:callback -> + next_nonce:Ethereum_types.quantity -> Ethereum_types.legacy_transaction_object -> Ethereum_types.hex -> unit tzresult Lwt.t @@ -65,6 +82,14 @@ val find : Ethereum_types.hash -> Ethereum_types.legacy_transaction_object option tzresult Lwt.t +(** [nonce ~next_nonce address] returns the first gap in the tx queue + for [address], or [next_nonce] if no transaction for [address] are + found. *) +val nonce : + next_nonce:Ethereum_types.quantity -> + Ethereum_types.address -> + Ethereum_types.quantity tzresult Lwt.t + (**/*) module Internal_for_tests : sig diff --git a/etherlink/bin_node/lib_dev/tx_queue_events.ml b/etherlink/bin_node/lib_dev/tx_queue_events.ml index 004f9b0d005f..bb36ccd0ebc6 100644 --- a/etherlink/bin_node/lib_dev/tx_queue_events.ml +++ b/etherlink/bin_node/lib_dev/tx_queue_events.ml @@ -49,6 +49,15 @@ let rpc_error = ("code", Data_encoding.int32) ("message", Data_encoding.string) +let callback_error = + declare_1 + ~section + ~name:"tx_queue_callback_error" + ~msg:"A callback produced an error: [@{error}@]" + ~level:Error + ~pp1:Error_monad.pp_print_trace + ("error", Events.trace_encoding) + let add_transaction = declare_1 ~name:"tx_queue_add_transaction" @@ -89,3 +98,5 @@ let transaction_confirmed tx = emit transaction_confirmed tx let rpc_error (error : Rpc_encodings.JSONRPC.error) = emit rpc_error (Int32.of_int error.code, error.message) + +let callback_error (error : tztrace) = emit callback_error error diff --git a/etherlink/bin_node/lib_dev/tx_queue_events.mli b/etherlink/bin_node/lib_dev/tx_queue_events.mli index d85061a5e24b..0cf612c01c48 100644 --- a/etherlink/bin_node/lib_dev/tx_queue_events.mli +++ b/etherlink/bin_node/lib_dev/tx_queue_events.mli @@ -34,3 +34,6 @@ val transaction_confirmed : Ethereum_types.hash -> unit Lwt.t (** [rpc_error error] advertises an RPC produced the error [error]. *) val rpc_error : Rpc_encodings.JSONRPC.error -> unit Lwt.t + +(** [callback_error error] advertises an RPC produced the error [error]. *) +val callback_error : tztrace -> unit Lwt.t diff --git a/etherlink/tezt/tests/evm_sequencer.ml b/etherlink/tezt/tests/evm_sequencer.ml index 7de66980904e..04a292eae4be 100644 --- a/etherlink/tezt/tests/evm_sequencer.ml +++ b/etherlink/tezt/tests/evm_sequencer.ml @@ -10979,6 +10979,241 @@ let test_tx_queue_clear = and* () = wait_for_clear in unit +let test_tx_queue_nonce = + register_all + ~tags:["observer"; "tx_queue"; "nonce"] + ~time_between_blocks:Nothing + ~kernels:[Latest] (* node only test *) + ~use_threshold_encryption:Register_without_feature + ~use_dal:Register_without_feature + ~websockets:false + ~title: + "Submits transactions to an observer with a tx queue and make sure it \ + can respond to getTransactionCount." + @@ fun {sequencer; observer; _} _protocol -> + let* () = Evm_node.terminate observer in + + let* () = + 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 *); + } + () + in + let* () = Evm_node.run observer in + + let* () = + let*@ _ = produce_block sequencer in + unit + and* () = Evm_node.wait_for_blueprint_applied observer 1 in + let check_nonce ~__LOC__ ~evm_node ~block ~expected = + let*@ nonce = + Rpc.get_transaction_count + evm_node + ~block + ~address:Eth_account.bootstrap_accounts.(0).address + in + Check.( + (Int64.to_int nonce = expected) + int + ~__LOC__ + ~error_msg:"Expected nonce %R found %L") ; + unit + in + + let check_nonce ~__LOC__ ?(check_observer = false) ?(check_sequencer = false) + ~block ~expected () = + let* () = + if check_observer then + check_nonce ~__LOC__ ~evm_node:observer ~block ~expected + else unit + in + if check_sequencer then + check_nonce ~__LOC__ ~evm_node:sequencer ~block ~expected + else unit + in + + (* helper to craft a tx with given nonce. *) + let send_raw_tx ~nonce = + let* raw_tx = + Cast.craft_tx + ~source_private_key:Eth_account.bootstrap_accounts.(0).private_key + ~chain_id:1337 + ~nonce + ~gas_price:1_000_000_000 + ~gas:23_300 + ~value:Wei.one + ~address:Eth_account.bootstrap_accounts.(1).address + () + in + Rpc.send_raw_transaction ~raw_tx observer + in + + let send_and_wait_sequencer_receive ~nonce = + let wait_sequencer_see_tx = + Evm_node.wait_for_tx_pool_add_transaction sequencer + in + let* _ = + let*@ _hash = send_raw_tx ~nonce in + unit + and* _ = wait_sequencer_see_tx in + unit + in + + let wait_for_all_tx_process ~nb_txs ~name ~waiter = + let rec aux total = + if total = nb_txs then ( + Log.info "All (%d) txs processed: \"%s\"." total name ; + unit) + else if total > nb_txs then + Test.fail + "more transaction where processed (%s) than expected, impossible" + name + else + let* nb = waiter () in + let total = total + nb in + Log.debug "Processed %d of txs. (%s)" total name ; + aux total + in + aux 0 + in + + (* Test start here *) + let* () = + check_nonce ~__LOC__ ~check_observer:true ~block:"pending" ~expected:0 () + in + + (* number of transactions that we are going to process (submit, + inject, ...) *) + let nb_txs = 5 in + Log.info + "Sending %d transactions to the observer and check after each that the \ + nonce in pending is correct" + nb_txs ; + let* _hashes = + fold nb_txs () @@ fun i () -> + let* () = send_and_wait_sequencer_receive ~nonce:i in + check_nonce + ~__LOC__ + ~check_observer:true + ~check_sequencer:true + ~block:"pending" + ~expected:(i + 1) + () + in + + let* () = + check_nonce + ~__LOC__ + ~check_observer:true + ~check_sequencer:true + ~block:"pending" + ~expected:nb_txs + () + in + + Log.info + "Send another txs to create a gap and check that the nonce in pending is \ + still the same" ; + let* () = send_and_wait_sequencer_receive ~nonce:(nb_txs + 1) in + + let* () = + check_nonce + ~__LOC__ + ~check_observer:true + ~check_sequencer:true + ~block:"pending" + ~expected:nb_txs + () + in + + Log.info + "Send missing nonce to fill the gap and check that the nonce in pending is \ + now correct" ; + let* () = send_and_wait_sequencer_receive ~nonce:nb_txs in + + Log.info + "produce enough block to include all txs and make sure the nonce of latest \ + and pending is equal." ; + (* Checks that all txs were confirmed in the observer *) + let observer_wait_tx_confirmed () = + let waiter () = + let* _ = Evm_node.wait_for_tx_queue_transaction_confirmed observer in + return 1 + in + wait_for_all_tx_process + ~nb_txs:(nb_txs + 2) + ~name:"tx confirmed in observer" + ~waiter + in + + (* Checks that all txs were included in a block by the sequencer *) + let sequencer_wait_tx_included () = + let waiter () = + let* _hash = Evm_node.wait_for_block_producer_tx_injected sequencer in + return 1 + in + wait_for_all_tx_process + ~nb_txs:(nb_txs + 2) + ~name:"tx included by sequencer" + ~waiter + in + + (* produce enough blocks to include all txs submited *) + let produce_block_until_all_included () = + let res = ref None in + let _p = + let* () = sequencer_wait_tx_included () in + res := Some () ; + unit + in + let result_f () = return !res in + bake_until + ~__LOC__ + ~bake:(fun () -> + let*@ _ = produce_block sequencer in + unit) + ~result_f + () + in + + let* () = produce_block_until_all_included () + and* () = observer_wait_tx_confirmed () in + + let* () = + check_nonce + ~__LOC__ + ~check_observer:true + ~check_sequencer:true + ~block:"pending" + ~expected:(nb_txs + 2) + () + in + let* () = + check_nonce + ~__LOC__ + ~check_observer:true + ~check_sequencer:true + ~block:"latest" + ~expected:(nb_txs + 2) + () + in + + Log.info "Try to send a transaction with a nonce in the past." ; + let*@? _hash = send_raw_tx ~nonce:(nb_txs + 1) in + + (* still true with a valid tx in pending. *) + let* () = send_and_wait_sequencer_receive ~nonce:(nb_txs + 3) in + let*@? _hash = send_raw_tx ~nonce:(nb_txs + 1) in + + Log.info "Try to send a transaction with an nonce already pending." ; + let* () = send_and_wait_sequencer_receive ~nonce:(nb_txs + 3) in + unit + let test_spawn_rpc = let fresh_port = Port.fresh () in register_all @@ -11210,4 +11445,5 @@ let () = test_tx_queue [Alpha] ; test_tx_queue_clear [Alpha] ; test_spawn_rpc protocols ; - test_observer_init_from_snapshot protocols + test_observer_init_from_snapshot protocols ; + test_tx_queue_nonce [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 011c0252f976..b652e2c9ae07 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 @@ -1382,6 +1382,14 @@ tx_pool_started: { /* tx_pool_started version 0 */ "tx_pool_started.v0": any } +tx_queue_callback_error: + description: A callback produced an error: [@{error}@] + level: error + section: evm_node.dev.tx_queue + json format: + { /* tx_queue_callback_error version 0 */ + "tx_queue_callback_error.v0": any } + tx_queue_rpc_error: description: an RPC produced the error : code:{code}, @@ -2376,6 +2384,14 @@ tx_pool_transaction_injection_failed: { /* tx_pool_transaction_injection_failed version 0 */ "tx_pool_transaction_injection_failed.v0": any } +tx_queue_callback_error: + description: A callback produced an error: [@{error}@] + level: error + section: evm_node.dev.tx_queue + json format: + { /* tx_queue_callback_error version 0 */ + "tx_queue_callback_error.v0": any } + tx_queue_rpc_error: description: an RPC produced the error : code:{code}, -- GitLab From 00b2b47b95ac36580df5472a11d6a4a22ea72ec8 Mon Sep 17 00:00:00 2001 From: Sylvain Ribstein Date: Fri, 28 Feb 2025 23:29:14 +0100 Subject: [PATCH 4/4] evm/node: replace add by replace in tx_queue hashtbl This is not part of that MR but while working on that I realised that stdlib hashtbl `add` function has the following semantics: > Warning: Previous bindings for key are not removed, but simply > hidden. That is, after performing Hashtbl.remove tbl key, the previous > binding for key, if any, is restored. (Same behavior as with > association lists.) which is not what I had in mind. the `replace` instead has the semantics I though `add` was. > Hashtbl.replace tbl key data replaces the current binding of key in > tbl by a binding of key to data. If key is unbound in tbl, a binding > of key to data is added to tbl. This is functionally equivalent to > Hashtbl.remove tbl key followed by Hashtbl.add tbl key data. --- etherlink/bin_node/lib_dev/tx_queue.ml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/etherlink/bin_node/lib_dev/tx_queue.ml b/etherlink/bin_node/lib_dev/tx_queue.ml index 4f6f3bd91d18..edaf3472eb6a 100644 --- a/etherlink/bin_node/lib_dev/tx_queue.ml +++ b/etherlink/bin_node/lib_dev/tx_queue.ml @@ -267,7 +267,7 @@ module Tx_object = struct let add htbl (({hash = Hash (Hex hash); _} : Ethereum_types.legacy_transaction_object) as tx_object) = - S.add htbl hash tx_object + S.replace htbl hash tx_object let find htbl (Hash (Hex hash)) = S.find htbl hash @@ -283,7 +283,7 @@ module Pending_transactions = struct let empty ~start_size = S.create start_size let add htbl (Hash (Hex hash)) pending_callback = - S.add + S.replace htbl hash ({pending_callback; since = Time.System.now ()} : pending_request) -- GitLab