From 8635b5ca86b8362c75396e195c5fe923df26d69b Mon Sep 17 00:00:00 2001 From: Alexander Filippov Date: Thu, 17 Oct 2024 18:08:53 +0300 Subject: [PATCH] feat: EIP-7702 support (#10870) * feat: return `authorizationList` for EIP-7702 transactions in `/transactions/:tx_hash` response (#10776) * feat: support EIP-7702 transactions * fix: handle invalid signatures * fix: save authority * Update apps/block_scout_web/lib/block_scout_web/views/api/v2/transaction_view.ex Co-authored-by: Kirill Fedoseev * Update apps/explorer/lib/explorer/chain/signed_authorization.ex Co-authored-by: Kirill Fedoseev * Update apps/indexer/lib/indexer/block/fetcher.ex Co-authored-by: Kirill Fedoseev * fix: remove set_code_transaction from @allowed_type_labels * Update apps/explorer/lib/explorer/chain/import/runner/signed_authorizations.ex Co-authored-by: Kirill Fedoseev * fix: move signed_authorization to a separate module * add todo --------- Co-authored-by: Kirill Fedoseev * feat: support EIP-7702 in `/address/:address` endpoint (#10799) * feat: support EIP-7702 transactions * fix: handle invalid signatures * fix: save authority * Update apps/block_scout_web/lib/block_scout_web/views/api/v2/transaction_view.ex Co-authored-by: Kirill Fedoseev * Update apps/explorer/lib/explorer/chain/signed_authorization.ex Co-authored-by: Kirill Fedoseev * Update apps/indexer/lib/indexer/block/fetcher.ex Co-authored-by: Kirill Fedoseev * fix: remove set_code_transaction from @allowed_type_labels * feat: add EIP-7702 support to /address/:address endpoint * fix: refactor fetch? * fix: move get_implementation_address_hash_string_eip7702 * fix: remove EIP-7702 flag from response, modify transactions filter to handle EOA with code correctly * fix: minor refactoring * fix: remove unused alias * fix: review comments * Update apps/explorer/priv/repo/migrations/20240904161254_create_signed_authorizations.exs Co-authored-by: Kirill Fedoseev --------- Co-authored-by: Kirill Fedoseev * fix: remove unused code * fix: refactor code * Update apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/signed_authorization.ex Co-authored-by: Alexander Kolotov * chore: documentation improvement * fix typo * fix: move spec and doc * fix: authorization_list spec * fix: wrap address and authority with checksum * fix: invalid typespec Co-authored-by: Alexander Kolotov * Apply suggestions from code review Co-authored-by: Alexander Kolotov * fix typo * Update apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_7702.ex Co-authored-by: Alexander Kolotov --------- Co-authored-by: Kirill Fedoseev Co-authored-by: Alexander Kolotov --- .../controllers/api/v2/address_controller.ex | 3 +- .../api/v2/transaction_controller.ex | 4 +- .../views/api/v2/transaction_view.ex | 65 ++++++++++- apps/ethereum_jsonrpc/lib/ethereum_jsonrpc.ex | 58 ++++++++- .../lib/ethereum_jsonrpc/fetched_code.ex | 54 ++++++++- .../lib/ethereum_jsonrpc/fetched_codes.ex | 38 +++++- .../ethereum_jsonrpc/signed_authorization.ex | 58 +++++++++ .../lib/ethereum_jsonrpc/transaction.ex | 31 ++++- apps/explorer/lib/explorer/chain/address.ex | 59 +++++++++- .../lib/explorer/chain/address/counters.ex | 49 +++++++- .../import/runner/signed_authorizations.ex | 110 ++++++++++++++++++ .../chain/import/stage/block_referencing.ex | 3 +- .../explorer/chain/signed_authorization.ex | 80 +++++++++++++ .../explorer/chain/smart_contract/proxy.ex | 30 ++++- .../chain/smart_contract/proxy/eip_7702.ex | 73 ++++++++++++ .../proxy/models/implementation.ex | 1 + .../lib/explorer/chain/transaction.ex | 11 +- apps/explorer/lib/explorer/counters/helper.ex | 10 ++ .../address_contract_code_fetch_attempt.ex | 27 ++++- ...904161254_create_signed_authorizations.exs | 25 ++++ .../20240918104231_new_proxy_type_eip7702.exs | 7 ++ apps/indexer/lib/indexer/block/fetcher.ex | 4 +- .../fetcher/on_demand/contract_code.ex | 53 ++++++++- .../transform/signed_authorizations.ex | 75 ++++++++++++ 24 files changed, 890 insertions(+), 38 deletions(-) create mode 100644 apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/signed_authorization.ex create mode 100644 apps/explorer/lib/explorer/chain/import/runner/signed_authorizations.ex create mode 100644 apps/explorer/lib/explorer/chain/signed_authorization.ex create mode 100644 apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_7702.ex create mode 100644 apps/explorer/priv/repo/migrations/20240904161254_create_signed_authorizations.exs create mode 100644 apps/explorer/priv/repo/migrations/20240918104231_new_proxy_type_eip7702.exs create mode 100644 apps/indexer/lib/indexer/transform/signed_authorizations.ex diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_controller.ex index 447d830313..012ecc6843 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/address_controller.ex @@ -77,7 +77,8 @@ defmodule BlockScoutWeb.API.V2.AddressController do :names => :optional, :scam_badge => :optional, :token => :optional, - :proxy_implementations => :optional + :proxy_implementations => :optional, + :signed_authorization => :optional }, api?: true ] diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/transaction_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/transaction_controller.ex index ce5e5bbed9..c828b27f57 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/transaction_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/transaction_controller.ex @@ -117,7 +117,9 @@ defmodule BlockScoutWeb.API.V2.TransactionController do @spec transaction(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} def transaction(conn, %{"transaction_hash_param" => transaction_hash_string} = params) do necessity_by_association_with_actions = - Map.put(@transaction_necessity_by_association, :transaction_actions, :optional) + @transaction_necessity_by_association + |> Map.put(:transaction_actions, :optional) + |> Map.put(:signed_authorizations, :optional) necessity_by_association = case Application.get_env(:explorer, :chain_type) do diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/transaction_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/transaction_view.ex index cf37c5a686..2c921347f1 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/transaction_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/transaction_view.ex @@ -8,7 +8,7 @@ defmodule BlockScoutWeb.API.V2.TransactionView do alias BlockScoutWeb.TransactionStateView alias Ecto.Association.NotLoaded alias Explorer.{Chain, Market} - alias Explorer.Chain.{Address, Block, Log, Token, Transaction, Wei} + alias Explorer.Chain.{Address, Block, Log, SignedAuthorization, Token, Transaction, Wei} alias Explorer.Chain.Block.Reward alias Explorer.Chain.Transaction.StateChange alias Explorer.Counters.AverageBlockTime @@ -209,6 +209,12 @@ defmodule BlockScoutWeb.API.V2.TransactionView do } end + def render("authorization_list.json", %{signed_authorizations: signed_authorizations}) do + signed_authorizations + |> Enum.sort_by(& &1.index, :asc) + |> Enum.map(&prepare_signed_authorization/1) + end + @doc """ Decodes list of logs """ @@ -287,6 +293,28 @@ defmodule BlockScoutWeb.API.V2.TransactionView do } end + @doc """ + Extracts the necessary fields from the signed authorization for the API response. + + ## Parameters + - `signed_authorization`: A `SignedAuthorization.t()` struct containing the signed authorization data. + + ## Returns + - A map with the necessary fields for the API response. + """ + @spec prepare_signed_authorization(SignedAuthorization.t()) :: map() + def prepare_signed_authorization(signed_authorization) do + %{ + "address" => Address.checksum(signed_authorization.address), + "chain_id" => signed_authorization.chain_id, + "nonce" => signed_authorization.nonce, + "r" => signed_authorization.r, + "s" => signed_authorization.s, + "v" => signed_authorization.v, + "authority" => Address.checksum(signed_authorization.authority) + } + end + defp get_tx_hash(%Transaction{} = tx), do: to_string(tx.hash) defp get_tx_hash(hash), do: to_string(hash) @@ -401,7 +429,8 @@ defmodule BlockScoutWeb.API.V2.TransactionView do "method" => Transaction.method_name(transaction, decoded_input), "tx_types" => tx_types(transaction), "tx_tag" => GetTransactionTags.get_transaction_tags(transaction.hash, current_user(single_tx? && conn)), - "has_error_in_internal_txs" => transaction.has_error_in_internal_txs + "has_error_in_internal_txs" => transaction.has_error_in_internal_txs, + "authorization_list" => authorization_list(transaction.signed_authorizations) } result @@ -433,6 +462,23 @@ defmodule BlockScoutWeb.API.V2.TransactionView do render("transaction_actions.json", %{actions: actions}) end + @doc """ + Renders the authorization list for a transaction. + + ## Parameters + - `signed_authorizations`: A list of `SignedAuthorization.t()` structs. + + ## Returns + - A list of maps with the necessary fields for the API response. + """ + @spec authorization_list(nil | NotLoaded.t() | [SignedAuthorization.t()]) :: [map()] + def authorization_list(nil), do: [] + def authorization_list(%NotLoaded{}), do: [] + + def authorization_list(signed_authorizations) do + render("authorization_list.json", %{signed_authorizations: signed_authorizations}) + end + defp burnt_fees(transaction, max_fee_per_gas, base_fee_per_gas) do if !is_nil(max_fee_per_gas) and !is_nil(transaction.gas_used) and !is_nil(base_fee_per_gas) do if Decimal.compare(max_fee_per_gas.value, 0) == :eq do @@ -559,7 +605,20 @@ defmodule BlockScoutWeb.API.V2.TransactionView do | :token_creation | :token_transfer | :blob_transaction - def tx_types(tx, types \\ [], stage \\ :blob_transaction) + | :set_code_transaction + def tx_types(tx, types \\ [], stage \\ :set_code_transaction) + + def tx_types(%Transaction{type: type} = tx, types, :set_code_transaction) do + # EIP-7702 set code transaction type + types = + if type == 4 do + [:set_code_transaction | types] + else + types + end + + tx_types(tx, types, :blob_transaction) + end def tx_types(%Transaction{type: type} = tx, types, :blob_transaction) do # EIP-2718 blob transaction type diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc.ex index 3d5cc09968..5c4d4a396e 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc.ex @@ -238,7 +238,26 @@ defmodule EthereumJSONRPC do end @doc """ - Fetches code for each given `address` at the `block_number`. + Fetches contract code for multiple addresses at specified block numbers. + + This function takes a list of parameters, each containing an address and a + block number, and retrieves the contract code for each address at the + specified block. + + ## Parameters + - `params_list`: A list of maps, each containing: + - `:block_quantity`: The block number (as a quantity string) at which to fetch the code. + - `:address`: The address of the contract to fetch the code for. + - `json_rpc_named_arguments`: A keyword list of JSON-RPC configuration options. + + ## Returns + - `{:ok, fetched_codes}`, where `fetched_codes` is a `FetchedCodes.t()` struct containing: + - `params_list`: A list of successfully fetched code parameters, each containing: + - `address`: The contract address. + - `block_number`: The block number at which the code was fetched. + - `code`: The fetched contract code in hexadecimal format. + - `errors`: A list of errors encountered during the fetch operation. + - `{:error, reason}`: An error occurred during the fetch operation. """ @spec fetch_codes( [%{required(:block_quantity) => quantity, required(:address) => address()}], @@ -423,7 +442,21 @@ defmodule EthereumJSONRPC do end @doc """ - Assigns an id to each set of params in `params_list` for batch request-response correlation + Assigns a unique integer ID to each set of parameters in the given list. + + This function is used to prepare parameters for batch request-response + correlation in JSON-RPC calls. + + ## Parameters + - `params_list`: A list of parameter sets, where each set can be of any type. + + ## Returns + A map where the keys are integer IDs (starting from 0) and the values are + the corresponding parameter sets from the input list. + + ## Example + iex> id_to_params([%{block: 1}, %{block: 2}]) + %{0 => %{block: 1}, 1 => %{block: 2}} """ @spec id_to_params([params]) :: %{id => params} when id: non_neg_integer(), params: any() def id_to_params(params_list) do @@ -433,8 +466,27 @@ defmodule EthereumJSONRPC do end @doc """ - Assigns not matched ids between requests and responses to responses with incorrect ids + Sanitizes responses by assigning unmatched IDs to responses with missing IDs. + + This function processes a list of responses and a map of expected IDs to + parameters. It handles cases where responses have missing (nil) IDs by + assigning them unmatched IDs from the id_to_params map. + + ## Parameters + - `responses`: A list of response maps from a batch JSON-RPC call. + - `id_to_params`: A map of request IDs to their corresponding parameters. + + ## Returns + A list of sanitized response maps where each response has a valid ID. + + ## Example + iex> responses = [%{id: 1, result: "ok"}, %{id: nil, result: "error"}] + iex> id_to_params = %{1 => %{}, 2 => %{}, 3 => %{}} + iex> EthereumJSONRPC.sanitize_responses(responses, id_to_params) + [%{id: 1, result: "ok"}, %{id: 2, result: "error"}] """ + @spec sanitize_responses(Transport.batch_response(), %{id => params}) :: Transport.batch_response() + when id: EthereumJSONRPC.request_id(), params: any() def sanitize_responses(responses, id_to_params) do responses |> Enum.reduce( diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_code.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_code.ex index 58d52509b4..2b7211fe4d 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_code.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_code.ex @@ -9,9 +9,37 @@ defmodule EthereumJSONRPC.FetchedCode do @type error :: %{code: integer(), message: String.t(), data: %{block_quantity: String.t(), address: String.t()}} @doc """ - Converts `response` to code params or annotated error. - """ + Converts a JSON-RPC response of `eth_getCode` to code params or an annotated error. + + This function handles two types of responses: + 1. Successful responses with fetched code. + 2. Error responses. + + ## Parameters + - `response`: A map containing either a successful result or an error. + - `id_to_params`: A map of request IDs to their corresponding parameters. + ## Returns + - `{:ok, params()}` for successful responses, where `params()` is a map + containing the address, block number, and fetched code. + - `{:error, error()}` for error responses, where `error()` is a map + containing the error code, message, and additional data. + + ## Examples + iex> # Successful response: + iex> response = %{id: 1, result: "0x123"} + iex> id_to_params = %{1 => %{block_quantity: "0x1", address: "0xabc"}} + iex> FetchedCode.from_response(response, id_to_params) + {:ok, %{address: "0xabc", block_number: 1, code: "0x123"}} + iex> # Error response: + iex> response = %{id: 1, error: %{code: 100, message: "Error"}} + iex> id_to_params = %{1 => %{block_quantity: "0x1", address: "0xabc"}} + iex> FetchedCode.from_response(response, id_to_params) + {:error, %{code: 100, message: "Error", data: %{block_quantity: "0x1", address: "0xabc"}}} + """ + @spec from_response(%{id: EthereumJSONRPC.request_id(), result: String.t()}, %{ + non_neg_integer() => %{block_quantity: String.t(), address: String.t()} + }) :: {:ok, params()} def from_response(%{id: id, result: fetched_code}, id_to_params) when is_map(id_to_params) do %{block_quantity: block_quantity, address: address} = Map.fetch!(id_to_params, id) @@ -23,9 +51,9 @@ defmodule EthereumJSONRPC.FetchedCode do }} end - @spec from_response(%{id: id, result: String.t()}, %{id => %{block_quantity: block_quantity, address: address}}) :: - {:ok, params()} - when id: non_neg_integer(), block_quantity: String.t(), address: String.t() + @spec from_response(%{id: EthereumJSONRPC.request_id(), error: %{code: integer(), message: String.t()}}, %{ + non_neg_integer() => %{block_quantity: String.t(), address: String.t()} + }) :: {:error, error()} def from_response(%{id: id, error: %{code: code, message: message} = error}, id_to_params) when is_integer(code) and is_binary(message) and is_map(id_to_params) do %{block_quantity: block_quantity, address: address} = Map.fetch!(id_to_params, id) @@ -35,6 +63,22 @@ defmodule EthereumJSONRPC.FetchedCode do {:error, annotated_error} end + @doc """ + Creates a standardized JSON-RPC request structure to fetch contract code using `eth_getCode`. + + ## Parameters + - `id`: The request identifier. + - `block_quantity`: The block number or tag (e.g., "latest") for which to + fetch the code. + - `address`: The address of the contract whose code is to be fetched. + + ## Returns + A map representing a JSON-RPC request with the following structure: + - `jsonrpc`: The JSON-RPC version (always "2.0"). + - `id`: The request identifier passed in. + - `method`: The RPC method name (always "eth_getCode"). + - `params`: A list containing the contract address and block identifier. + """ @spec request(%{id: id, block_quantity: block_quantity, address: address}) :: %{ jsonrpc: String.t(), id: id, diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_codes.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_codes.ex index 2570379909..a15ce4f8ce 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_codes.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/fetched_codes.ex @@ -15,7 +15,22 @@ defmodule EthereumJSONRPC.FetchedCodes do @type t :: %__MODULE__{params_list: [FetchedCode.params()], errors: [FetchedCode.error()]} @doc """ - `eth_getCode` requests for `id_to_params`. + Generates a list of `eth_getCode` JSON-RPC requests for the given parameters. + + This function takes a map of request IDs to parameter maps and transforms them + into a list of JSON-RPC request structures for fetching contract code. + + ## Parameters + - `id_to_params`: A map where keys are request IDs and values are maps + containing `block_quantity` and `address` for each request. + + ## Returns + A list of maps, each representing a JSON-RPC request with the following + structure: + - `jsonrpc`: The JSON-RPC version (always "2.0"). + - `id`: The request identifier. + - `method`: The RPC method name (always "eth_getCode"). + - `params`: A list containing the contract address and block identifier. """ @spec requests(%{id => %{block_quantity: block_quantity, address: address}}) :: [ %{jsonrpc: String.t(), id: id, method: String.t(), params: [address | block_quantity]} @@ -30,8 +45,27 @@ defmodule EthereumJSONRPC.FetchedCodes do end @doc """ - Converts `responses` to `t/0`. + Processes responses from `eth_getCode` JSON-RPC calls and converts them into a structured format. + + This function takes a list of responses from `eth_getCode` calls and a map of + request IDs to their corresponding parameters. It sanitizes the responses, + processes each one, and accumulates the results into a `FetchedCodes` struct. + + ## Parameters + - `responses`: A list of response maps from `eth_getCode` calls. Each map + contains an `:id` key and either a `:result` or `:error` key. + - `id_to_params`: A map where keys are request IDs and values are maps + containing `block_quantity` and `address` for each request. + + ## Returns + A `FetchedCodes` struct containing: + - `params_list`: A list of successfully fetched code parameters. + - `errors`: A list of errors encountered during the process. """ + @spec from_responses( + [%{:id => EthereumJSONRPC.request_id(), optional(:error) => map(), optional(:result) => String.t()}], + %{non_neg_integer() => %{block_quantity: String.t(), address: String.t()}} + ) :: t() def from_responses(responses, id_to_params) do responses |> EthereumJSONRPC.sanitize_responses(id_to_params) diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/signed_authorization.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/signed_authorization.ex new file mode 100644 index 0000000000..943183a3d8 --- /dev/null +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/signed_authorization.ex @@ -0,0 +1,58 @@ +defmodule EthereumJSONRPC.SignedAuthorization do + @moduledoc """ + The format of authorization tuples returned for + set code transactions [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702). + """ + + import EthereumJSONRPC, only: [quantity_to_integer: 1] + + @typedoc """ + * `"chainId"` - specifies the chain for which the authorization was created `t:EthereumJSONRPC.quantity/0`. + * `"address"` - `t:EthereumJSONRPC.address/0` of the delegate contract. + * `"nonce"` - signature nonce `t:EthereumJSONRPC.quantity/0`. + * `"v"` - v component of the signature `t:EthereumJSONRPC.quantity/0`. + * `"r"` - r component of the signature `t:EthereumJSONRPC.quantity/0`. + * `"s"` - s component of the signature `t:EthereumJSONRPC.quantity/0`. + """ + @type t :: %{ + String.t() => EthereumJSONRPC.address() | EthereumJSONRPC.quantity() + } + + @typedoc """ + * `"chain_id"` - specifies the chain for which the authorization was created. + * `"address"` - address of the delegate contract. + * `"nonce"` - signature nonce. + * `"v"` - v component of the signature. + * `"r"` - r component of the signature. + * `"s"` - s component of the signature. + """ + @type params :: %{ + chain_id: non_neg_integer(), + address: EthereumJSONRPC.address(), + nonce: non_neg_integer(), + r: non_neg_integer(), + s: non_neg_integer(), + v: non_neg_integer() + } + + @doc """ + Converts a signed authorization map into its corresponding parameters map format. + + ## Parameters + - `raw`: Map with signed authorization data. + + ## Returns + - Parameters map in the `params()` format. + """ + @spec to_params(t()) :: params() + def to_params(raw) do + %{ + chain_id: quantity_to_integer(raw["chainId"]), + address: raw["address"], + nonce: quantity_to_integer(raw["nonce"]), + r: quantity_to_integer(raw["r"]), + s: quantity_to_integer(raw["s"]), + v: quantity_to_integer(raw["v"]) + } + end +end diff --git a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transaction.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transaction.ex index 0287f699b0..c61e74f4c6 100644 --- a/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transaction.ex +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/transaction.ex @@ -16,6 +16,7 @@ defmodule EthereumJSONRPC.Transaction do ] alias EthereumJSONRPC + alias EthereumJSONRPC.SignedAuthorization case Application.compile_env(:explorer, :chain_type) do :ethereum -> @@ -74,8 +75,16 @@ defmodule EthereumJSONRPC.Transaction do @chain_type_fields quote(do: []) end + # todo: Check if it's possible to simplify by avoiding t -> elixir -> params conversions + # and directly convert t -> params. @type elixir :: %{ - String.t() => EthereumJSONRPC.address() | EthereumJSONRPC.hash() | String.t() | non_neg_integer() | nil + String.t() => + EthereumJSONRPC.address() + | EthereumJSONRPC.hash() + | String.t() + | non_neg_integer() + | [SignedAuthorization.params()] + | nil } @typedoc """ @@ -105,6 +114,7 @@ defmodule EthereumJSONRPC.Transaction do * `"maxPriorityFeePerGas"` - `t:EthereumJSONRPC.quantity/0` of wei to denote max priority fee per unit of gas used. Introduced in [EIP-1559](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1559.md) * `"maxFeePerGas"` - `t:EthereumJSONRPC.quantity/0` of wei to denote max fee per unit of gas used. Introduced in [EIP-1559](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1559.md) * `"type"` - `t:EthereumJSONRPC.quantity/0` denotes transaction type. Introduced in [EIP-1559](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1559.md) + * `"authorizationList"` - `t:list/0` of `t:EthereumJSONRPC.SignedAuthorization.t/0` authorization tuples. Introduced in [EIP-7702](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-7702.md) #{case Application.compile_env(:explorer, :chain_type) do :ethereum -> """ * `"maxFeePerBlobGas"` - `t:EthereumJSONRPC.quantity/0` of wei to denote max fee per unit of blob gas used. Introduced in [EIP-4844](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-4844.md) @@ -128,7 +138,12 @@ defmodule EthereumJSONRPC.Transaction do """ @type t :: %{ String.t() => - EthereumJSONRPC.address() | EthereumJSONRPC.hash() | EthereumJSONRPC.quantity() | String.t() | nil + EthereumJSONRPC.address() + | EthereumJSONRPC.hash() + | EthereumJSONRPC.quantity() + | String.t() + | [SignedAuthorization.t()] + | nil } @type params :: %{ @@ -150,7 +165,8 @@ defmodule EthereumJSONRPC.Transaction do transaction_index: non_neg_integer(), max_priority_fee_per_gas: non_neg_integer(), max_fee_per_gas: non_neg_integer(), - type: non_neg_integer() + type: non_neg_integer(), + authorization_list: [SignedAuthorization.params()] } @doc """ @@ -322,7 +338,8 @@ defmodule EthereumJSONRPC.Transaction do {"block_timestamp", :block_timestamp}, {"r", :r}, {"s", :s}, - {"v", :v} + {"v", :v}, + {"authorizationList", :authorization_list} ]) end @@ -368,7 +385,8 @@ defmodule EthereumJSONRPC.Transaction do {"block_timestamp", :block_timestamp}, {"r", :r}, {"s", :s}, - {"v", :v} + {"v", :v}, + {"authorizationList", :authorization_list} ]) end @@ -700,6 +718,9 @@ defmodule EthereumJSONRPC.Transaction do end end + defp entry_to_elixir({"authorizationList" = key, value}), + do: {key, value |> Enum.map(&SignedAuthorization.to_params/1)} + # Celo-specific fields if Application.compile_env(:explorer, :chain_type) == :celo do defp entry_to_elixir({key, value}) diff --git a/apps/explorer/lib/explorer/chain/address.ex b/apps/explorer/lib/explorer/chain/address.ex index 700cbb126c..a8f7b86417 100644 --- a/apps/explorer/lib/explorer/chain/address.ex +++ b/apps/explorer/lib/explorer/chain/address.ex @@ -13,6 +13,7 @@ defmodule Explorer.Chain.Address.Schema do DecompiledSmartContract, Hash, InternalTransaction, + SignedAuthorization, SmartContract, Token, Transaction, @@ -107,6 +108,10 @@ defmodule Explorer.Chain.Address.Schema do has_many(:decompiled_smart_contracts, DecompiledSmartContract, foreign_key: :address_hash, references: :hash) has_many(:withdrawals, Withdrawal, foreign_key: :address_hash, references: :hash) + # In practice, this is a one-to-many relationship, but we only need to check if any signed authorization + # exists for a given address. This done this way to avoid loading all signed authorizations for an address. + has_one(:signed_authorization, SignedAuthorization, foreign_key: :authority, references: :hash) + timestamps() unquote_splicing(@chain_type_fields) @@ -130,8 +135,9 @@ defmodule Explorer.Chain.Address do alias Explorer.Helper, as: ExplorerHelper alias Explorer.Chain.Cache.{Accounts, NetVersion} alias Explorer.Chain.SmartContract.Proxy + alias Explorer.Chain.SmartContract.Proxy.EIP7702 alias Explorer.Chain.SmartContract.Proxy.Models.Implementation - alias Explorer.Chain.{Address, Hash} + alias Explorer.Chain.{Address, Data, Hash} alias Explorer.{Chain, PagingOptions, Repo} @optional_attrs ~w(contract_code fetched_coin_balance fetched_coin_balance_block_number nonce decompiled verified gas_used transactions_count token_transfers_count)a @@ -422,7 +428,18 @@ defmodule Explorer.Chain.Address do end @doc """ - Checks if given address is smart-contract + Determines if the given address is a smart contract. + + This function checks the contract code of an address to determine if it's a + smart contract. + + ## Parameters + - `address`: The address to check. Can be an `Address` struct or any other value. + + ## Returns + - `true` if the address is a smart contract + - `false` if the address is not a smart contract + - `nil` if the contract code hasn't been loaded """ @spec smart_contract?(any()) :: boolean() | nil def smart_contract?(%__MODULE__{contract_code: nil}), do: false @@ -430,6 +447,31 @@ defmodule Explorer.Chain.Address do def smart_contract?(%NotLoaded{}), do: nil def smart_contract?(_), do: false + @doc """ + Checks if the given address is an Externally Owned Account (EOA) with code, + as defined in EIP-7702. + + This function determines whether an address represents an EOA that has + associated code, which is a special case introduced by EIP-7702. It checks + the contract code of the address for the presence of a delegate address + according to the EIP-7702 specification. + + ## Parameters + - `address`: The address to check. Can be an `Address` struct or any other value. + + ## Returns + - `true` if the address is an EOA with code (EIP-7702 compliant) + - `false` if the address is not an EOA with code + - `nil` if the contract code hasn't been loaded + """ + @spec eoa_with_code?(any()) :: boolean() | nil + def eoa_with_code?(%__MODULE__{contract_code: %Data{bytes: code}}) do + EIP7702.get_delegate_address(code) != nil + end + + def eoa_with_code?(%NotLoaded{}), do: nil + def eoa_with_code?(_), do: false + defp get_addresses(options) do accounts_with_n = fetch_top_addresses(options) @@ -529,7 +571,18 @@ defmodule Explorer.Chain.Address do end @doc """ - Sets contract_code for the given Explorer.Chain.Address + Sets the contract code for the given address. + + This function updates the contract code and the `updated_at` timestamp for an + address in the database. + + ## Parameters + - `address_hash`: The hash of the address to update. + - `contract_code`: The new contract code to set. + + ## Returns + A tuple `{count, nil}`, where `count` is the number of rows updated + (typically 1 if the address exists, 0 otherwise). """ @spec set_contract_code(Hash.Address.t(), binary()) :: {non_neg_integer(), nil} def set_contract_code(address_hash, contract_code) when not is_nil(address_hash) and is_binary(contract_code) do diff --git a/apps/explorer/lib/explorer/chain/address/counters.ex b/apps/explorer/lib/explorer/chain/address/counters.ex index 9ac501b434..3fdb09acb1 100644 --- a/apps/explorer/lib/explorer/chain/address/counters.ex +++ b/apps/explorer/lib/explorer/chain/address/counters.ex @@ -181,6 +181,20 @@ defmodule Explorer.Chain.Address.Counters do Repo.aggregate(to_address_query, :count, :hash, timeout: :infinity) end + @doc """ + Calculates the total gas used by incoming transactions to a given address. + + This function queries the database for all transactions where the + `to_address_hash` matches the provided `address_hash`, and sums up the + `gas_used` for these transactions. + + ## Parameters + - `address_hash`: The address hash to query for incoming transactions. + + ## Returns + - The total gas used by incoming transactions, or `nil` if no transactions + are found or if the sum is null. + """ @spec address_to_incoming_transaction_gas_usage(Hash.Address.t()) :: Decimal.t() | nil def address_to_incoming_transaction_gas_usage(address_hash) do to_address_query = @@ -192,6 +206,19 @@ defmodule Explorer.Chain.Address.Counters do Repo.aggregate(to_address_query, :sum, :gas_used, timeout: :infinity) end + @doc """ + Calculates the total gas used by outgoing transactions from a given address. + + This function queries the database for all transactions where the + `from_address_hash` matches the provided `address_hash`, and sums up the + `gas_used` for these transactions. + + ## Parameters + - `address_hash`: the address to query. + + ## Returns + - The total gas used, or `nil` if no transactions are found or if the sum is null. + """ @spec address_to_outcoming_transaction_gas_usage(Hash.Address.t()) :: Decimal.t() | nil def address_to_outcoming_transaction_gas_usage(address_hash) do to_address_query = @@ -226,9 +253,29 @@ defmodule Explorer.Chain.Address.Counters do ) end + @doc """ + Calculates the total gas usage for a given address. + + This function determines the appropriate gas usage calculation based on the + address type: + + - For smart contracts (excluding EOAs with code), it first checks the gas + usage of incoming transactions. If there are no incoming transactions or + their gas usage is zero, it falls back to the gas usage of outgoing + transactions. + - For regular addresses and EOAs with code, it calculates the gas usage of + outgoing transactions. + + ## Parameters + - `address`: The address to calculate gas usage for. + + ## Returns + - The total gas usage for the address. + - `nil` if no relevant transactions are found or if the sum is null. + """ @spec address_to_gas_usage_count(Address.t()) :: Decimal.t() | nil def address_to_gas_usage_count(address) do - if Address.smart_contract?(address) do + if Address.smart_contract?(address) and not Address.eoa_with_code?(address) do incoming_transaction_gas_usage = address_to_incoming_transaction_gas_usage(address.hash) cond do diff --git a/apps/explorer/lib/explorer/chain/import/runner/signed_authorizations.ex b/apps/explorer/lib/explorer/chain/import/runner/signed_authorizations.ex new file mode 100644 index 0000000000..147f5c821d --- /dev/null +++ b/apps/explorer/lib/explorer/chain/import/runner/signed_authorizations.ex @@ -0,0 +1,110 @@ +defmodule Explorer.Chain.Import.Runner.SignedAuthorizations do + @moduledoc """ + Bulk imports `t:Explorer.Chain.SignedAuthorization.t/0`. + """ + + require Ecto.Query + + import Ecto.Query, only: [from: 2] + + alias Ecto.{Changeset, Multi, Repo} + alias Explorer.Chain.{Import, SignedAuthorization} + alias Explorer.Prometheus.Instrumenter + + @behaviour Import.Runner + + # milliseconds + @timeout 60_000 + + @type imported :: [SignedAuthorization.t()] + + @impl Import.Runner + def ecto_schema_module, do: SignedAuthorization + + @impl Import.Runner + def option_key, do: :signed_authorizations + + @impl Import.Runner + def imported_table_row do + %{ + value_type: "[#{ecto_schema_module()}.t()]", + value_description: "List of `t:#{ecto_schema_module()}.t/0`s" + } + end + + @impl Import.Runner + def run(multi, changes_list, %{timestamps: timestamps} = options) do + insert_options = + options + |> Map.get(option_key(), %{}) + |> Map.take(~w(on_conflict timeout)a) + |> Map.put_new(:timeout, @timeout) + |> Map.put(:timestamps, timestamps) + + Multi.run(multi, :signed_authorizations, fn repo, _ -> + Instrumenter.block_import_stage_runner( + fn -> insert(repo, changes_list, insert_options) end, + :block_referencing, + :signed_authorizations, + :signed_authorizations + ) + end) + end + + @impl Import.Runner + def timeout, do: @timeout + + @spec insert(Repo.t(), [map()], %{ + optional(:on_conflict) => Import.Runner.on_conflict(), + required(:timeout) => timeout, + required(:timestamps) => Import.timestamps() + }) :: + {:ok, [SignedAuthorization.t()]} + | {:error, [Changeset.t()]} + defp insert(repo, changes_list, %{timeout: timeout, timestamps: timestamps} = options) when is_list(changes_list) do + on_conflict = Map.get_lazy(options, :on_conflict, &default_on_conflict/0) + conflict_target = [:transaction_hash, :index] + + {:ok, _} = + Import.insert_changes_list( + repo, + changes_list, + for: SignedAuthorization, + on_conflict: on_conflict, + conflict_target: conflict_target, + returning: true, + timeout: timeout, + timestamps: timestamps + ) + end + + defp default_on_conflict do + from( + authorization in SignedAuthorization, + update: [ + set: [ + chain_id: fragment("EXCLUDED.chain_id"), + address: fragment("EXCLUDED.address"), + nonce: fragment("EXCLUDED.nonce"), + r: fragment("EXCLUDED.r"), + s: fragment("EXCLUDED.s"), + v: fragment("EXCLUDED.v"), + authority: fragment("EXCLUDED.authority"), + inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", authorization.inserted_at), + updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", authorization.updated_at) + ] + ], + where: + fragment( + "(EXCLUDED.chain_id, EXCLUDED.address, EXCLUDED.nonce, EXCLUDED.r, EXCLUDED.s, EXCLUDED.v, EXCLUDED.authority) IS DISTINCT FROM (?, ?, ?, ?, ?, ?, ?)", + authorization.chain_id, + authorization.address, + authorization.nonce, + authorization.r, + authorization.s, + authorization.v, + authorization.authority + ) + ) + end +end diff --git a/apps/explorer/lib/explorer/chain/import/stage/block_referencing.ex b/apps/explorer/lib/explorer/chain/import/stage/block_referencing.ex index df3b0c264d..621a712168 100644 --- a/apps/explorer/lib/explorer/chain/import/stage/block_referencing.ex +++ b/apps/explorer/lib/explorer/chain/import/stage/block_referencing.ex @@ -14,7 +14,8 @@ defmodule Explorer.Chain.Import.Stage.BlockReferencing do Runner.Tokens, Runner.TokenInstances, Runner.TransactionActions, - Runner.Withdrawals + Runner.Withdrawals, + Runner.SignedAuthorizations ] @extra_runners_by_chain_type %{ diff --git a/apps/explorer/lib/explorer/chain/signed_authorization.ex b/apps/explorer/lib/explorer/chain/signed_authorization.ex new file mode 100644 index 0000000000..f43d847eea --- /dev/null +++ b/apps/explorer/lib/explorer/chain/signed_authorization.ex @@ -0,0 +1,80 @@ +defmodule Explorer.Chain.SignedAuthorization do + @moduledoc "Models a transaction extension with authorization tuples from eip7702 set code transactions." + + use Explorer.Schema + + alias Explorer.Chain.{Hash, Transaction} + + @optional_attrs ~w(authority)a + @required_attrs ~w(transaction_hash index chain_id address nonce r s v)a + + @typedoc """ + Descriptor of the signed authorization tuple from EIP-7702 set code transactions: + * `transaction_hash` - the hash of the associated transaction. + * `index` - the index of this authorization in the authorization list. + * `chain_id` - the ID of the chain for which the authorization was created. + * `address` - the address of the delegate contract. + * `nonce` - the signature nonce. + * `v` - the 'v' component of the signature. + * `r` - the 'r' component of the signature. + * `s` - the 's' component of the signature. + * `authority` - the signer of the authorization. + """ + @type to_import :: %__MODULE__{ + transaction_hash: binary(), + index: non_neg_integer(), + chain_id: non_neg_integer(), + address: binary(), + nonce: non_neg_integer(), + r: non_neg_integer(), + s: non_neg_integer(), + v: non_neg_integer(), + authority: binary() | nil + } + + @typedoc """ + * `transaction_hash` - the hash of the associated transaction. + * `index` - the index of this authorization in the authorization list. + * `chain_id` - the ID of the chain for which the authorization was created. + * `address` - the address of the delegate contract. + * `nonce` - the signature nonce. + * `v` - the 'v' component of the signature. + * `r` - the 'r' component of the signature. + * `s` - the 's' component of the signature. + * `authority` - the signer of the authorization. + * `inserted_at` - timestamp indicating when the signed authorization was created. + * `updated_at` - timestamp indicating when the signed authorization was last updated. + * `transaction` - an instance of `Explorer.Chain.Transaction` referenced by `transaction_hash`. + """ + @primary_key false + typed_schema "signed_authorizations" do + field(:index, :integer, primary_key: true, null: false) + field(:chain_id, :integer, null: false) + field(:address, Hash.Address, null: false) + field(:nonce, :integer, null: false) + field(:r, :decimal, null: false) + field(:s, :decimal, null: false) + field(:v, :integer, null: false) + field(:authority, Hash.Address, null: true) + + belongs_to(:transaction, Transaction, + foreign_key: :transaction_hash, + primary_key: true, + references: :hash, + type: Hash.Full + ) + + timestamps() + end + + @doc """ + Validates that the `attrs` are valid. + """ + @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Schema.t() + def changeset(%__MODULE__{} = struct, attrs \\ %{}) do + struct + |> cast(attrs, @required_attrs ++ @optional_attrs) + |> validate_required(@required_attrs) + |> foreign_key_constraint(:transaction_hash) + end +end diff --git a/apps/explorer/lib/explorer/chain/smart_contract/proxy.ex b/apps/explorer/lib/explorer/chain/smart_contract/proxy.ex index 50bafa0944..7e1e26d869 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract/proxy.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract/proxy.ex @@ -15,6 +15,7 @@ defmodule Explorer.Chain.SmartContract.Proxy do EIP1822, EIP1967, EIP2535, + EIP7702, EIP930, MasterCopy } @@ -116,7 +117,20 @@ defmodule Explorer.Chain.SmartContract.Proxy do end @doc """ - Decodes address output into 20 bytes address hash + Decodes and formats an address output from a smart contract ABI. + + This function handles various input formats and edge cases when decoding + address outputs from smart contract function calls or events. + + ## Parameters + - `address`: The address output to decode. Can be `nil`, `"0x"`, a binary string, or `:error`. + + ## Returns + - `nil` if the input is `nil`. + - The burn address hash string if the input is `"0x"`. + - A formatted address string if the input is a valid binary string. + - `:error` if the input is `:error`. + - `nil` for any other input type. """ @spec abi_decode_address_output(any()) :: nil | :error | binary() def abi_decode_address_output(nil), do: nil @@ -250,6 +264,20 @@ defmodule Explorer.Chain.SmartContract.Proxy do proxy_abi, go_to_fallback? ], + :get_implementation_address_hash_string_eip7702 + ) + end + + @doc """ + Returns EIP-7702 implementation address or tries next proxy pattern + """ + @spec get_implementation_address_hash_string_eip7702(Hash.Address.t(), any(), bool()) :: + %{implementation_address_hash_strings: [String.t()] | :error | nil, proxy_type: atom() | :unknown} + def get_implementation_address_hash_string_eip7702(proxy_address_hash, proxy_abi, go_to_fallback?) do + get_implementation_address_hash_string_by_module( + EIP7702, + :eip7702, + [proxy_address_hash, proxy_abi, go_to_fallback?], :get_implementation_address_hash_string_eip1967 ) end diff --git a/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_7702.ex b/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_7702.ex new file mode 100644 index 0000000000..eb349460fe --- /dev/null +++ b/apps/explorer/lib/explorer/chain/smart_contract/proxy/eip_7702.ex @@ -0,0 +1,73 @@ +defmodule Explorer.Chain.SmartContract.Proxy.EIP7702 do + @moduledoc """ + Module for fetching EOA delegate from https://eips.ethereum.org/EIPS/eip-7702 + """ + + alias Explorer.Chain + alias Explorer.Chain.{Address, Hash} + alias Explorer.Chain.SmartContract.Proxy + + @doc """ + Retrieves the delegate address hash string for an EIP-7702 compatible EOA. + + This function fetches the contract code for the given address and extracts + the delegate address according to the EIP-7702 specification. + + ## Parameters + - `address_hash`: The address of the contract to check. + - `options`: Optional keyword list of options (default: `[]`). + + ## Returns + - The delegate address in the hex string format if found and successfully decoded. + - `nil` if the address doesn't exist, has no contract code, or the delegate address + couldn't be extracted or decoded. + """ + @spec get_implementation_address_hash_string(Hash.Address.t(), Keyword.t()) :: String.t() | nil + @spec get_implementation_address_hash_string(Hash.Address.t()) :: String.t() | nil + def get_implementation_address_hash_string(address_hash, options \\ []) do + case Chain.select_repo(options).get(Address, address_hash) do + nil -> + nil + + target_address -> + contract_code = target_address.contract_code + + case contract_code do + %Chain.Data{bytes: contract_code_bytes} -> + contract_code_bytes |> get_delegate_address() |> Proxy.abi_decode_address_output() + + _ -> + nil + end + end + end + + @doc """ + Extracts the EIP-7702 delegate address from the bytecode. + + This function analyzes the given bytecode to identify and extract the delegate + address according to the EIP-7702 specification. + + ## Parameters + - `contract_code_bytes`: The binary representation of the contract bytecode. + + ## Returns + - A string representation of the delegate address prefixed with "0x" if found. + - `nil` if the delegate address is not present in the bytecode. + + ## Examples + iex> get_delegate_address(<<239, 1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20>>) + "0x0102030405060708090a0b0c0d0e0f10111213" + + iex> get_delegate_address(<<1, 2, 3>>) + nil + """ + @spec get_delegate_address(binary()) :: String.t() | nil + def get_delegate_address(contract_code_bytes) do + case contract_code_bytes do + # 0xef0100 <> address + <<239, 1, 0>> <> <> -> "0x" <> Base.encode16(address, case: :lower) + _ -> nil + end + end +end diff --git a/apps/explorer/lib/explorer/chain/smart_contract/proxy/models/implementation.ex b/apps/explorer/lib/explorer/chain/smart_contract/proxy/models/implementation.ex index 728e0710e5..fd5387e35a 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract/proxy/models/implementation.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract/proxy/models/implementation.ex @@ -49,6 +49,7 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do :comptroller, :eip2535, :clone_with_immutable_arguments, + :eip7702, :unknown ], null: true diff --git a/apps/explorer/lib/explorer/chain/transaction.ex b/apps/explorer/lib/explorer/chain/transaction.ex index b6f2f73454..af9014f528 100644 --- a/apps/explorer/lib/explorer/chain/transaction.ex +++ b/apps/explorer/lib/explorer/chain/transaction.ex @@ -16,6 +16,7 @@ defmodule Explorer.Chain.Transaction.Schema do Hash, InternalTransaction, Log, + SignedAuthorization, TokenTransfer, TransactionAction, Wei @@ -270,6 +271,11 @@ defmodule Explorer.Chain.Transaction.Schema do type: Hash.Address ) + has_many(:signed_authorizations, SignedAuthorization, + foreign_key: :transaction_hash, + references: :hash + ) + unquote_splicing(@chain_type_fields) end end @@ -1958,12 +1964,13 @@ defmodule Explorer.Chain.Transaction do @doc """ Dynamically adds to/from for `transactions` query based on whether the target address EOA or smart-contract - todo: pay attention to [EIP-5003](https://eips.ethereum.org/EIPS/eip-5003): if it will be included, this logic should be rolled back. + EOAs with code (EIP-7702) are treated as regular EOAs. """ @spec where_transactions_to_from(Hash.Address.t()) :: any() def where_transactions_to_from(address_hash) do with {:ok, address} <- Chain.hash_to_address(address_hash), - true <- Address.smart_contract?(address) do + true <- Address.smart_contract?(address), + false <- Address.eoa_with_code?(address) do dynamic([transaction], transaction.to_address_hash == ^address_hash) else _ -> diff --git a/apps/explorer/lib/explorer/counters/helper.ex b/apps/explorer/lib/explorer/counters/helper.ex index 6a461df321..2ec17a78bc 100644 --- a/apps/explorer/lib/explorer/counters/helper.ex +++ b/apps/explorer/lib/explorer/counters/helper.ex @@ -10,6 +10,16 @@ defmodule Explorer.Counters.Helper do read_concurrency: true ] + @doc """ + Returns the current time in milliseconds since the Unix epoch. + + This function retrieves the current UTC time and converts it to Unix + timestamp in milliseconds. + + ## Returns + - The number of milliseconds since the Unix epoch. + """ + @spec current_time() :: non_neg_integer() def current_time do utc_now = DateTime.utc_now() diff --git a/apps/explorer/lib/explorer/utility/address_contract_code_fetch_attempt.ex b/apps/explorer/lib/explorer/utility/address_contract_code_fetch_attempt.ex index 951a401a08..167584ab35 100644 --- a/apps/explorer/lib/explorer/utility/address_contract_code_fetch_attempt.ex +++ b/apps/explorer/lib/explorer/utility/address_contract_code_fetch_attempt.ex @@ -23,7 +23,15 @@ defmodule Explorer.Utility.AddressContractCodeFetchAttempt do end @doc """ - Gets retries number and updated_at for the Explorer.Chain.Address + Retrieves the number of retries and the last update timestamp for a given address. + + ## Parameters + - `address_hash`: The address to query. + + ## Returns + - `{retries_number, updated_at}`: A tuple containing the number of retries and + the last update timestamp. + - `nil`: If no record is found for the given address. """ @spec get_retries_number(Hash.Address.t()) :: {non_neg_integer(), DateTime.t()} | nil def get_retries_number(address_hash) do @@ -37,7 +45,15 @@ defmodule Explorer.Utility.AddressContractCodeFetchAttempt do end @doc """ - Deletes row from address_contract_code_fetch_attempts table for the given address + Deletes the entry from the `address_contract_code_fetch_attempts` table that corresponds to the provided address hash. + + ## Parameters + - `address_hash`: The `t:Explorer.Chain.Hash.Address.t/0` of the address + whose fetch attempt record should be deleted. + + ## Returns + A tuple `{count, nil}`, where `count` is the number of records deleted + (typically 1 if the record existed, 0 otherwise). """ @spec delete(Hash.Address.t()) :: {non_neg_integer(), nil} def delete(address_hash) do @@ -47,14 +63,13 @@ defmodule Explorer.Utility.AddressContractCodeFetchAttempt do end @doc """ - Inserts the number of retries for fetching contract code for a given address. + Inserts the number of retries for fetching contract code for a given address. - ## Parameters + ## Parameters - `address_hash` - The hash of the address for which the retries number is to be inserted. - ## Returns + ## Returns The result of the insertion operation. - """ @spec insert_retries_number(Hash.Address.t()) :: {non_neg_integer(), nil | [term()]} def insert_retries_number(address_hash) do diff --git a/apps/explorer/priv/repo/migrations/20240904161254_create_signed_authorizations.exs b/apps/explorer/priv/repo/migrations/20240904161254_create_signed_authorizations.exs new file mode 100644 index 0000000000..e37cd4297f --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20240904161254_create_signed_authorizations.exs @@ -0,0 +1,25 @@ +defmodule Explorer.Repo.Migrations.CreateSignedAuthorizations do + use Ecto.Migration + + def change do + create table(:signed_authorizations, primary_key: false) do + add(:transaction_hash, references(:transactions, column: :hash, on_delete: :delete_all, type: :bytea), + null: false, + primary_key: true + ) + + add(:index, :integer, null: false, primary_key: true) + add(:chain_id, :bigint, null: false) + add(:address, :bytea, null: false) + add(:nonce, :integer, null: false) + add(:v, :integer, null: false) + add(:r, :numeric, precision: 100, null: false) + add(:s, :numeric, precision: 100, null: false) + add(:authority, :bytea, null: true) + + timestamps(null: false, type: :utc_datetime_usec) + end + + create(index(:signed_authorizations, [:authority, :nonce])) + end +end diff --git a/apps/explorer/priv/repo/migrations/20240918104231_new_proxy_type_eip7702.exs b/apps/explorer/priv/repo/migrations/20240918104231_new_proxy_type_eip7702.exs new file mode 100644 index 0000000000..85c1ef80c8 --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20240918104231_new_proxy_type_eip7702.exs @@ -0,0 +1,7 @@ +defmodule Explorer.Repo.Migrations.NewProxyTypeEip7702 do + use Ecto.Migration + + def change do + execute("ALTER TYPE proxy_type ADD VALUE 'eip7702' BEFORE 'unknown'") + end +end diff --git a/apps/indexer/lib/indexer/block/fetcher.ex b/apps/indexer/lib/indexer/block/fetcher.ex index c86d5443f1..7ab0f66905 100644 --- a/apps/indexer/lib/indexer/block/fetcher.ex +++ b/apps/indexer/lib/indexer/block/fetcher.ex @@ -44,6 +44,7 @@ defmodule Indexer.Block.Fetcher do Addresses, AddressTokenBalances, MintTransfers, + SignedAuthorizations, TokenInstances, TokenTransfers, TransactionActions @@ -236,7 +237,8 @@ defmodule Indexer.Block.Fetcher do tokens: %{params: tokens}, transactions: %{params: transactions_with_receipts}, withdrawals: %{params: withdrawals_params}, - token_instances: %{params: token_instances} + token_instances: %{params: token_instances}, + signed_authorizations: %{params: SignedAuthorizations.parse(transactions_with_receipts)} }, chain_type_import_options = %{ transactions_with_receipts: transactions_with_receipts, diff --git a/apps/indexer/lib/indexer/fetcher/on_demand/contract_code.ex b/apps/indexer/lib/indexer/fetcher/on_demand/contract_code.ex index 3b4823005a..12385f2f46 100644 --- a/apps/indexer/lib/indexer/fetcher/on_demand/contract_code.ex +++ b/apps/indexer/lib/indexer/fetcher/on_demand/contract_code.ex @@ -24,8 +24,23 @@ defmodule Indexer.Fetcher.OnDemand.ContractCode do end end + # Attempts to fetch the contract code for a given address. + # + # This function checks if the contract code needs to be fetched and if enough time + # has passed since the last attempt. If conditions are met, it triggers the fetch + # and broadcast process. + # + # ## Parameters + # address: The address of the contract. + # state: The current state of the fetcher, containing JSON-RPC configuration. + # + # ## Returns + # `:ok` in all cases. + @spec fetch_contract_code(Address.t(), %{ + json_rpc_named_arguments: EthereumJSONRPC.json_rpc_named_arguments() + }) :: :ok defp fetch_contract_code(address, state) do - with {:empty_nonce, true} <- {:empty_nonce, is_nil(address.nonce)}, + with {:need_to_fetch, true} <- {:need_to_fetch, fetch?(address)}, {:retries_number, {retries_number, updated_at}} <- {:retries_number, AddressContractCodeFetchAttempt.get_retries_number(address.hash)}, updated_at_ms = DateTime.to_unix(updated_at, :millisecond), @@ -35,7 +50,7 @@ defmodule Indexer.Fetcher.OnDemand.ContractCode do threshold(retries_number)} do fetch_and_broadcast_bytecode(address.hash, state) else - {:empty_nonce, false} -> + {:need_to_fetch, false} -> :ok {:retries_number, nil} -> @@ -47,7 +62,35 @@ defmodule Indexer.Fetcher.OnDemand.ContractCode do end end - defp fetch_and_broadcast_bytecode(address_hash, state) do + # Determines if contract code should be fetched for an address + @spec fetch?(Address.t()) :: boolean() + defp fetch?(address) when is_nil(address.nonce), do: true + # if the address has a signed authorization, it might have a bytecode + # according to EIP-7702 + defp fetch?(%{signed_authorization: %{authority: _}}), do: true + defp fetch?(_), do: false + + # Fetches and broadcasts the bytecode for a given address. + # + # This function attempts to retrieve the contract bytecode for the specified address + # using the Ethereum JSON-RPC API. If successful, it updates the database as described below + # and broadcasts the result: + # 1. Updates the `addresses` table with the contract code if fetched successfully. + # 2. Modifies the `address_contract_code_fetch_attempts` table: + # - Deletes the entry if the code is successfully set. + # - Increments the retry count if the fetch fails or returns empty code. + # 3. Broadcasts a message with the fetched bytecode if successful. + # + # ## Parameters + # address_hash: The `t:Explorer.Chain.Hash.Address.t/0` of the contract. + # state: The current state of the fetcher, containing JSON-RPC configuration. + # + # ## Returns + # `:ok` (the function always returns `:ok`, actual results are handled via side effects) + @spec fetch_and_broadcast_bytecode(Explorer.Chain.Hash.Address.t(), %{ + json_rpc_named_arguments: EthereumJSONRPC.json_rpc_named_arguments() + }) :: :ok + defp fetch_and_broadcast_bytecode(address_hash, %{json_rpc_named_arguments: _} = state) do with {:fetched_code, {:ok, %EthereumJSONRPC.FetchedCodes{params_list: fetched_codes}}} <- {:fetched_code, fetch_codes( @@ -90,10 +133,14 @@ defmodule Indexer.Fetcher.OnDemand.ContractCode do {:noreply, state} end + # An initial threshold to fetch smart-contract bytecode on-demand + @spec update_threshold_ms() :: non_neg_integer() defp update_threshold_ms do Application.get_env(:indexer, __MODULE__)[:threshold] end + # Calculates the delay for the next fetch attempt based on the number of retries + @spec threshold(non_neg_integer()) :: non_neg_integer() defp threshold(retries_number) do delay_in_ms = trunc(update_threshold_ms() * :math.pow(2, retries_number)) diff --git a/apps/indexer/lib/indexer/transform/signed_authorizations.ex b/apps/indexer/lib/indexer/transform/signed_authorizations.ex new file mode 100644 index 0000000000..b7e5f8e509 --- /dev/null +++ b/apps/indexer/lib/indexer/transform/signed_authorizations.ex @@ -0,0 +1,75 @@ +defmodule Indexer.Transform.SignedAuthorizations do + @moduledoc """ + Helper functions for extracting signed authorizations from EIP-7702 transactions. + """ + + alias Explorer.Chain.{Hash, SignedAuthorization} + + # The magic number used in EIP-7702 to prefix the message to be signed. + @eip7702_magic 0x5 + + @doc """ + Extracts signed authorizations from a list of transactions with receipts. + + This function parses the authorization tuples from EIP-7702 set code transactions, + recovers the authority address from the signature, and prepares the data for database import. + + ## Parameters + - `transactions_with_receipts`: A list of transactions with receipts. + + ## Returns + A list of signed authorizations ready for database import. + """ + @spec parse([ + %{optional(:authorization_list) => [EthereumJSONRPC.SignedAuthorization.params()], optional(any()) => any()} + ]) :: [SignedAuthorization.to_import()] + def parse(transactions_with_receipts) do + transactions_with_receipts + |> Enum.filter(&Map.has_key?(&1, :authorization_list)) + |> Enum.flat_map( + &(&1.authorization_list + |> Enum.with_index() + |> Enum.map(fn {authorization, index} -> + authorization + |> Map.merge(%{ + transaction_hash: &1.hash, + index: index, + authority: recover_authority(authorization) + }) + end)) + ) + end + + # This function recovers the signer address from the signed authorization data using this formula: + # authority = ecrecover(keccak(MAGIC || rlp([chain_id, address, nonce])), y_parity, r, s] + @spec recover_authority(EthereumJSONRPC.SignedAuthorization.params()) :: String.t() | nil + defp recover_authority(signed_authorization) do + {:ok, %{bytes: address}} = Hash.Address.cast(signed_authorization.address) + + signed_message = + ExKeccak.hash_256( + <<@eip7702_magic>> <> ExRLP.encode([signed_authorization.chain_id, address, signed_authorization.nonce]) + ) + + authority = + ec_recover(signed_message, signed_authorization.r, signed_authorization.s, signed_authorization.v) + + authority + end + + # This function uses elliptic curve recovery to get the address from the signed message and the signature. + @spec ec_recover(binary(), non_neg_integer(), non_neg_integer(), non_neg_integer()) :: EthereumJSONRPC.address() | nil + defp ec_recover(signed_message, r, s, v) do + r_bytes = <> + s_bytes = <> + + with {:ok, <<_compression::bytes-size(1), public_key::binary>>} <- + ExSecp256k1.recover(signed_message, r_bytes, s_bytes, v), + <<_::bytes-size(12), hash::binary>> <- ExKeccak.hash_256(public_key) do + address = Base.encode16(hash, case: :lower) + "0x" <> address + else + _ -> nil + end + end +end