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 ce5e5bbed9c6..c828b27f57a8 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 c950a69fa53c..645e0d0f56f8 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 @@ -210,6 +210,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 """ @@ -336,6 +342,18 @@ defmodule BlockScoutWeb.API.V2.TransactionView do } end + def prepare_signed_authorization(signed_authorization) do + %{ + "address" => 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" => signed_authorization.authority + } + end + defp get_tx_hash(%Transaction{} = tx), do: to_string(tx.hash) defp get_tx_hash(hash), do: to_string(hash) @@ -450,7 +468,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 @@ -482,6 +501,13 @@ defmodule BlockScoutWeb.API.V2.TransactionView do render("transaction_actions.json", %{actions: actions}) end + 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 @@ -608,7 +634,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/signed_authorization.ex b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/signed_authorization.ex new file mode 100644 index 000000000000..85559fe3c54d --- /dev/null +++ b/apps/ethereum_jsonrpc/lib/ethereum_jsonrpc/signed_authorization.ex @@ -0,0 +1,44 @@ +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() + } + + @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 `t:t/0` to `t:params/0` + """ + @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 0287f699b03a..c61e74f4c612 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/import/runner/signed_authorizations.ex b/apps/explorer/lib/explorer/chain/import/runner/signed_authorizations.ex new file mode 100644 index 000000000000..147f5c821dc7 --- /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 df3b0c264d79..621a712168b4 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 000000000000..a479e7cdb6a7 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/signed_authorization.ex @@ -0,0 +1,58 @@ +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 + + @type t :: %__MODULE__{ + transaction_hash: Hash.Full, + index: :integer, + chain_id: :integer, + address: Hash.Address, + nonce: :integer, + r: :decimal, + s: :decimal, + v: :integer, + authority: Hash.Address + } + + @primary_key false + schema "signed_authorizations" do + field(:index, :integer, primary_key: true) + field(:chain_id, :integer) + field(:address, Hash.Address) + field(:nonce, :integer) + field(:r, :decimal) + field(:s, :decimal) + field(:v, :integer) + field(:authority, Hash.Address) + + 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/transaction.ex b/apps/explorer/lib/explorer/chain/transaction.ex index b6f2f734546f..fc729c4c5e3e 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 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 000000000000..be633ffbe42a --- /dev/null +++ b/apps/explorer/priv/repo/migrations/20240904161254_create_signed_authorizations.exs @@ -0,0 +1,23 @@ +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 + end +end diff --git a/apps/indexer/lib/indexer/block/fetcher.ex b/apps/indexer/lib/indexer/block/fetcher.ex index c86d5443f12c..a89c8ce61dbb 100644 --- a/apps/indexer/lib/indexer/block/fetcher.ex +++ b/apps/indexer/lib/indexer/block/fetcher.ex @@ -236,7 +236,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: extract_signed_authorizations(transactions_with_receipts)} }, chain_type_import_options = %{ transactions_with_receipts: transactions_with_receipts, @@ -745,6 +746,52 @@ defmodule Indexer.Block.Fetcher do end) end + defp extract_signed_authorizations(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 + + defp recover_authority(signed_authorization) do + eip7702_magic = 0x5 + {:ok, %{bytes: address}} = Hash.Address.cast(signed_authorization.address) + + signed_message = + ExKeccak.hash_256( + <> <> 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 + + 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 + # Asynchronously schedules matching of Arbitrum L1-to-L2 messages where the message ID is hashed. @spec async_match_arbitrum_messages_to_l2([map()]) :: :ok defp async_match_arbitrum_messages_to_l2([]), do: :ok