From 1aa59850a2dfc57bb4be4e35854bc033b88ba4f4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:35:27 +0300 Subject: [PATCH 01/18] chore(deps): bump oban from 2.22.1 to 2.23.1 (#14682) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- mix.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.lock b/mix.lock index ea9aea0e86c..55fa200ed0a 100644 --- a/mix.lock +++ b/mix.lock @@ -126,7 +126,7 @@ "numbers": {:hex, :numbers, "5.2.4", "f123d5bb7f6acc366f8f445e10a32bd403c8469bdbce8ce049e1f0972b607080", [:mix], [{:coerce, "~> 1.0", [hex: :coerce, repo: "hexpm", optional: false]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "eeccf5c61d5f4922198395bf87a465b6f980b8b862dd22d28198c5e6fab38582"}, "nx": {:hex, :nx, "0.12.1", "6e9fee43a77646d04faad2ba4e449b9e270c6b23e413f203cb4d81e71c0a617f", [:mix], [{:complex, "~> 0.7", [hex: :complex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ee80f6ae898f68bbfe7f30216b06aab10096231ec99ded1208a827256b23d0fb"}, "oauth2": {:hex, :oauth2, "2.1.1", "3bec9bf49e88e1b0c0ddf9f44c393d71fd0f88e905766f2e2cc5d57e6bcc5352", [:mix], [{:tesla, "~> 1.18", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "1d5997cb1ff1643dac17076b6c00c91e4381d8389f3c49a8c390984606dad439"}, - "oban": {:hex, :oban, "2.22.1", "9d2a38cec95070b31c1e274fae55f3925089f62159d8f3facabee0454d55b257", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "af2508c156c5b0ec30b21b0883babf7e2716af35ed5d264095896103fe3cea37"}, + "oban": {:hex, :oban, "2.23.1", "0b9495e28a236ca0478c80d666c58a8b2b55182731c3603c6e766e298a08342a", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a9855b9f5d87e31de3e2f46731b163f372e329613892a56c5b2aacceb50ed508"}, "open_api_spex": {:hex, :open_api_spex, "3.22.3", "0e383bf23cc3a060bffaebbcd09fc06bfc908d948c00e518aed36bbf8a2fe473", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "5f74f1878fdc38f8e961b0b943ac7af88dcf3a82a0c0ef6680ddfd3d161aecbd"}, "optimal": {:hex, :optimal, "0.3.6", "46bbf52fbbbd238cda81e02560caa84f93a53c75620f1fe19e81e4ae7b07d1dd", [:mix], [], "hexpm", "1a06ea6a653120226b35b283a1cd10039550f2c566edcdec22b29316d73640fd"}, "parallel_stream": {:hex, :parallel_stream, "1.1.0", "f52f73eb344bc22de335992377413138405796e0d0ad99d995d9977ac29f1ca9", [:mix], [], "hexpm", "684fd19191aedfaf387bbabbeb8ff3c752f0220c8112eb907d797f4592d6e871"}, @@ -145,7 +145,7 @@ "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, "poison": {:hex, :poison, "5.0.0", "d2b54589ab4157bbb82ec2050757779bfed724463a544b6e20d79855a9e43b24", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "11dc6117c501b80c62a7594f941d043982a1bd05a1184280c0d9166eb4d8d3fc"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, - "postgrex": {:hex, :postgrex, "0.22.3", "bf65941737ee7a9adbe4a64c91080310d11703da343e8ac9188aacb9eb9f6f02", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "f018c13752b2b46e8d35d7e2d84c3276557cbfd880769109021a1d0ee36c1cfe"}, + "postgrex": {:hex, :postgrex, "0.22.4", "d271f595dfd25230b6398354e19d17bb5e2d20130fd2d9bdca7e15f125d43552", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "4aae45a2d60e35b04eea2602440be152fae332901f1fc7a60fc7cb7f0f9a9c5a"}, "prometheus": {:hex, :prometheus, "6.1.3", "67c85683a265f36d35559bdddd49a34d857d4827888ba987c8c8b7954a3df80e", [:rebar3], [{:ddskerl, "0.4.3", [hex: :ddskerl, repo: "hexpm", optional: false]}], "hexpm", "bd9522b4dd21cf6670f18a647d1ef91ab2f633f02f9275b583fad1eb11f880bf"}, "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "8d66289f77f913b37eda81fd287340c17e61a447549deb28efc254532b2bed82"}, "prometheus_ex": {:hex, :prometheus_ex, "5.1.0", "a978945b4be5923b87edae3537e3fd61f8d04d755fae865ec4cbb1be7b5ec2e5", [:mix], [{:prometheus, "~> 6.1", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm", "15d3cd752063a1b51ffcba727fc0276f62d2a4387b523963883d125ea5e0d9e7"}, From b3ca3f6cc78f12f546fc543da86c611472d48b2f Mon Sep 17 00:00:00 2001 From: nikitosing <32202610+nikitosing@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:36:07 +0300 Subject: [PATCH 02/18] feat: Add /api/v2/transactions/{hash}/preview endpoint (#14638) --- .../api/v2/transaction_controller.ex | 69 +++++++++++++++++++ .../lib/block_scout_web/routers/api_router.ex | 1 + .../views/api/v2/transaction_view.ex | 34 +++++++++ 3 files changed, 104 insertions(+) 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 d7f5c4e4d14..2aea01ce30a 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 @@ -1045,6 +1045,75 @@ defmodule BlockScoutWeb.API.V2.TransactionController do end end + @preview_necessity_by_association %{ + :block => :optional, + [from_address: [:names, :smart_contract]] => :optional, + [to_address: [:names, :smart_contract]] => :optional + } + + operation :preview, + summary: "Get lightweight transaction preview for social media embeds", + description: "Returns minimal transaction data (status, timestamp, method, from/to) for rendering OG previews.", + parameters: + [transaction_hash_param() | base_params()] ++ + [ + %OpenApiSpex.Parameter{ + name: :preload_ens, + in: :query, + schema: %Schema{type: :boolean}, + required: false, + description: "Preload ENS domain names for addresses (default: false)" + }, + %OpenApiSpex.Parameter{ + name: :preload_metadata, + in: :query, + schema: %Schema{type: :boolean}, + required: false, + description: "Preload address metadata/name tags (default: false)" + }, + %OpenApiSpex.Parameter{ + name: :decode_input, + in: :query, + schema: %Schema{type: :boolean}, + required: false, + description: "Decode transaction input to resolve method name (default: false)" + } + ], + responses: [ + ok: {"Lightweight transaction preview.", "application/json", %Schema{type: :object}}, + not_found: NotFoundResponse.response(), + unprocessable_entity: JsonErrorResponse.response() + ] + + @doc """ + Function to handle GET requests to `/api/v2/transactions/:transaction_hash_param/preview` endpoint. + """ + @spec preview(Plug.Conn.t(), map()) :: Plug.Conn.t() | {atom(), any()} + def preview(conn, %{transaction_hash_param: transaction_hash_string} = params) do + options = + [necessity_by_association: @preview_necessity_by_association] + |> Keyword.merge(@api_true) + + with {:ok, transaction, _transaction_hash} <- validate_transaction(transaction_hash_string, params, options) do + preloaded = + transaction + |> maybe_preload_preview_ens(params) + |> maybe_preload_preview_metadata(params) + + conn + |> put_status(200) + |> render(:preview, %{transaction: preloaded, decode_input: params[:decode_input] == true}) + end + end + + defp maybe_preload_preview_ens(transaction, %{preload_ens: true}), do: maybe_preload_ens_to_transaction(transaction) + defp maybe_preload_preview_ens(transaction, _params), do: transaction + + defp maybe_preload_preview_metadata(transaction, %{preload_metadata: true}), + do: maybe_preload_metadata_to_transaction(transaction) + + defp maybe_preload_preview_metadata(transaction, _params), do: transaction + operation :blobs, summary: "List blobs for a transaction", description: "Retrieves blobs for a specific transaction (Ethereum only).", diff --git a/apps/block_scout_web/lib/block_scout_web/routers/api_router.ex b/apps/block_scout_web/lib/block_scout_web/routers/api_router.ex index a94af802da9..95f20f7b514 100644 --- a/apps/block_scout_web/lib/block_scout_web/routers/api_router.ex +++ b/apps/block_scout_web/lib/block_scout_web/routers/api_router.ex @@ -190,6 +190,7 @@ defmodule BlockScoutWeb.Routers.ApiRouter do get("/:transaction_hash_param/raw-trace", V2.TransactionController, :raw_trace) get("/:transaction_hash_param/state-changes", V2.TransactionController, :state_changes) get("/:transaction_hash_param/summary", V2.TransactionController, :summary) + get("/:transaction_hash_param/preview", V2.TransactionController, :preview) chain_scope :neon do get("/:transaction_hash_param/external-transactions", V2.TransactionController, :external_transactions) 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 4edf0f243cf..a9606ee6b57 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 @@ -86,6 +86,22 @@ defmodule BlockScoutWeb.API.V2.TransactionView do |> prepare_transaction(conn, true, block_height, nil, decoded_input, nil) end + def render("preview.json", %{transaction: transaction, decode_input: decode_input}) do + decoded_input = + if decode_input do + [decoded] = Transaction.decode_transactions([transaction], true, @api_true) + decoded + end + + %{ + "status" => transaction.status, + "timestamp" => block_timestamp(transaction), + "method" => Transaction.method_name(transaction, decoded_input), + "from" => preview_address(transaction.from_address, transaction.from_address_hash), + "to" => preview_address(transaction.to_address, transaction.to_address_hash) + } + end + def render("raw_trace.json", %{raw_traces: raw_traces}) do raw_traces end @@ -900,6 +916,24 @@ defmodule BlockScoutWeb.API.V2.TransactionView do def block_timestamp(%Block{} = block), do: block.timestamp def block_timestamp(_), do: nil + defp preview_address(%Address{} = address, _hash) do + %{ + "hash" => Address.checksum(address), + "name" => Helper.address_name(address), + "ens_domain_name" => address.ens_domain_name + } + end + + defp preview_address(_, nil), do: nil + + defp preview_address(_, hash) do + %{ + "hash" => Address.checksum(hash), + "name" => nil, + "ens_domain_name" => nil + } + end + defp prepare_state_change(%StateChange{} = state_change) do coin_or_transfer = if state_change.coin_or_token_transfers == :coin, From dc043abd3ea5fac1b75a9b2006fc9921e734be05 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:36:37 +0300 Subject: [PATCH 03/18] chore(deps): bump tesla from 1.20.0 to 1.21.0 (#14683) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apps/ethereum_jsonrpc/mix.exs | 2 +- apps/explorer/mix.exs | 2 +- apps/utils/mix.exs | 2 +- mix.exs | 2 +- mix.lock | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/ethereum_jsonrpc/mix.exs b/apps/ethereum_jsonrpc/mix.exs index 7433a6b65eb..485489e80f3 100644 --- a/apps/ethereum_jsonrpc/mix.exs +++ b/apps/ethereum_jsonrpc/mix.exs @@ -76,7 +76,7 @@ defmodule EthereumJSONRPC.MixProject do {:spandex, "~> 3.0"}, # `:spandex` integration with Datadog {:spandex_datadog, "~> 1.0"}, - {:tesla, "~> 1.20.0"}, + {:tesla, "~> 1.21.0"}, # Convert unix timestamps in JSONRPC to DateTimes {:timex, "~> 3.7.1"}, # Encode/decode function names and arguments diff --git a/apps/explorer/mix.exs b/apps/explorer/mix.exs index 3382654152a..d94edb3db66 100644 --- a/apps/explorer/mix.exs +++ b/apps/explorer/mix.exs @@ -116,7 +116,7 @@ defmodule Explorer.Mixfile do # `Timex.Duration` for `Explorer.Chain.Cache.Counters.AverageBlockTime.average_block_time/0` {:timex, "~> 3.7.1"}, {:con_cache, "~> 1.0"}, - {:tesla, "~> 1.20.0"}, + {:tesla, "~> 1.21.0"}, {:cbor, "~> 1.0"}, {:cloak_ecto, "~> 1.3.0"}, {:redix, "~> 1.1"}, diff --git a/apps/utils/mix.exs b/apps/utils/mix.exs index fc8d69df8be..54fad5d6e18 100644 --- a/apps/utils/mix.exs +++ b/apps/utils/mix.exs @@ -34,7 +34,7 @@ defmodule Utils.MixProject do {:credo, "~> 1.5", only: [:test, :dev], runtime: false}, {:httpoison, "~> 2.0"}, {:mime, "~> 2.0"}, - {:tesla, "~> 1.20.0"} + {:tesla, "~> 1.21.0"} ] end diff --git a/mix.exs b/mix.exs index 6f0a72c7c54..7d06eb55891 100644 --- a/mix.exs +++ b/mix.exs @@ -106,7 +106,7 @@ defmodule BlockScout.Mixfile do [ {:prometheus_ex, "~> 5.1.0", override: true}, {:absinthe_plug, git: "https://github.com/blockscout/absinthe_plug.git", tag: "1.5.8", override: true}, - {:tesla, "~> 1.20.0"}, + {:tesla, "~> 1.21.0"}, {:mint, "~> 1.9.0"}, # Documentation {:ex_doc, "~> 0.40.1", only: :dev, runtime: false}, diff --git a/mix.lock b/mix.lock index 55fa200ed0a..3964f792e62 100644 --- a/mix.lock +++ b/mix.lock @@ -171,7 +171,7 @@ "statistex": {:hex, :statistex, "1.1.1", "73612aa7f79e53c30569be065fd121e380f1cf57bc4c2da5b41be9246da18df9", [:mix], [], "hexpm", "310c4b49b34adf683de3103639006bed233ab54c08a4add65a531448e653857c"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, - "tesla": {:hex, :tesla, "1.20.0", "922b504e186da2ee82990d44e7422435f41b67403908a00340c3a0b23a17aa90", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21 or >= 4.0.2 and < 5.0.0-0", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "3ecb41cb458772332752c3acdfe983e23abb991f5a43cfd69a64e9ea3f4b0061"}, + "tesla": {:hex, :tesla, "1.21.0", "de3dc7b0ddbbd72a2fdab02decc977c0fa910aa04cbfb92aaca5e93a839ad2a5", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21 or >= 4.0.2 and < 5.0.0-0", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "1a0b8c9a7c3676beafd33db5e13f5e486af936c1a9a9f56b037e7861d916d333"}, "timex": {:hex, :timex, "3.7.13", "0688ce11950f5b65e154e42b47bf67b15d3bc0e0c3def62199991b8a8079a1e2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "09588e0522669328e973b8b4fd8741246321b3f0d32735b589f78b136e6d4c54"}, "ton": {:hex, :ton, "0.5.1", "79745434a93e5f7de3572fdcf04feb048620f0edab9794fc412a73528672927d", [:mix], [{:cafezinho, "~> 0.4.4", [hex: :cafezinho, repo: "hexpm", optional: false]}, {:evil_crc32c, "~> 0.2.9", [hex: :evil_crc32c, repo: "hexpm", optional: false]}, {:ex_pbkdf2, "~> 0.8.4", [hex: :ex_pbkdf2, repo: "hexpm", optional: false]}, {:mnemoniac, "~> 0.1.4", [hex: :mnemoniac, repo: "hexpm", optional: false]}], "hexpm", "916f656c870902a61690347da9500c5ce27f04c02e02441363bac7b128030f07"}, "typed_ecto_schema": {:hex, :typed_ecto_schema, "0.4.3", "1e5f3b6c763f9b5725975d3ab7f1554525f1f1399b966f2425acf04f9d8dd4fe", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "dcbd9b35b9fda5fa9258e0ae629a99cf4473bd7adfb85785d3f71dfe7a9b2bc0"}, From 6389e9ebe03633856d9fec6e4ae8184eb4147806 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:36:57 +0300 Subject: [PATCH 04/18] chore(deps-dev): bump sobelow from 0.14.1 to 0.15.0 (#14685) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- mix.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.lock b/mix.lock index 3964f792e62..18f5ffe5cf8 100644 --- a/mix.lock +++ b/mix.lock @@ -162,7 +162,7 @@ "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"}, "siwe": {:git, "https://github.com/royal-markets/siwe-ex.git", "51c9c08240eb7eea3c35693011f8d260cd9bb3be", [ref: "51c9c08240eb7eea3c35693011f8d260cd9bb3be"]}, "sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"}, - "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, + "sobelow": {:hex, :sobelow, "0.15.0", "b067d7f8522a9d758fa89cb2bfcbab7ad72c45a0993cb958c989c6fd956fdd56", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "24a800e2d7fa8c3bd21561b6ad8ad4745ed726a09fd606598981d9048708da98"}, "spandex": {:hex, :spandex, "3.2.0", "f8cd40146ea988c87f3c14054150c9a47ba17e53cd4515c00e1f93c29c45404d", [:mix], [{:decorator, "~> 1.2", [hex: :decorator, repo: "hexpm", optional: true]}, {:optimal, "~> 0.3.3", [hex: :optimal, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d0a7d5aef4c5af9cf5467f2003e8a5d8d2bdae3823a6cc95d776b9a2251d4d03"}, "spandex_datadog": {:hex, :spandex_datadog, "1.4.0", "0594b9655b0af00ab9137122616bc0208b68ceec01e9916ab13d6fbb33dcce35", [:mix], [{:msgpax, "~> 2.2.1 or ~> 2.3", [hex: :msgpax, repo: "hexpm", optional: false]}, {:spandex, "~> 3.2", [hex: :spandex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "360f8e1b4db238c1749c4872b1697b096429927fa42b8858d0bb782067380123"}, "spandex_ecto": {:hex, :spandex_ecto, "0.7.0", "259ad2feb7c834e774ec623f99c0fbacca8d60a73be212f92b75e37f853c81be", [:mix], [{:spandex, "~> 2.2 or ~> 3.0", [hex: :spandex, repo: "hexpm", optional: false]}], "hexpm", "c64784be79d95538013b7c60828830411c5c7aff1f4e8d66dfe564b3c83b500e"}, From e95dc6b473345ddd19ab53e8b9017f510d46cf20 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:07:54 +0300 Subject: [PATCH 05/18] chore(deps-dev): bump ex_machina from 2.8.1 to 2.8.2 (#14684) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- mix.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.lock b/mix.lock index 18f5ffe5cf8..4db9833011e 100644 --- a/mix.lock +++ b/mix.lock @@ -65,7 +65,7 @@ "ex_hash_ring": {:hex, :ex_hash_ring, "6.0.4", "bef9d2d796afbbe25ab5b5a7ed746e06b99c76604f558113c273466d52fa6d6b", [:mix], [], "hexpm", "89adabf31f7d3dfaa36802ce598ce918e9b5b33bae8909ac1a4d052e1e567d18"}, "ex_json_schema": {:hex, :ex_json_schema, "0.11.3", "0f9128fde2a46976c21d1ed9506bf8462776ac9e333e7b42ececcdbf7433a1da", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "0e8e16866bc14339b1bf1e441f1d9231031c8fe2cf45fa5a988a8b685de8c002"}, "ex_keccak": {:hex, :ex_keccak, "0.7.8", "be1cf194d3158f0a305eaed0334e478d0d0f2c827e7c1f8f0e1e2a667da5a8ac", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "52de5b42b718df2534fb9a55780d8a05bbaea539f867c3e7c0a8e7e1d5f149d9"}, - "ex_machina": {:hex, :ex_machina, "2.8.1", "be50262d132107c2e3f9ccbbe0124118b944e3c2480459b59a712befc59f0d52", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "f25b8aab1c2765507a595f8fa1bbd3c180357fae49f8cdc720747f7ce5128cf8"}, + "ex_machina": {:hex, :ex_machina, "2.8.2", "f0a46a6aeb1198577bf8e34625c69ee0f24bf3230e79e3e17b27fcb1d682a70d", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "42f551b2a3a26d138788160d44e391a6bf31266af48125c2b935f24521ebd644"}, "ex_pbkdf2": {:hex, :ex_pbkdf2, "0.8.5", "583733a1134a0c8b4df70c6b3e874d1896bf48f2aaafdf332ec5480c7b5117bf", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "f138b2acdc5a4d87880e42742e9bead4d1235fd9b1b2b29be0d1da1e01c3a312"}, "ex_rlp": {:hex, :ex_rlp, "0.6.0", "985391d2356a7cb8712a4a9a2deb93f19f2fbca0323f5c1203fcaf64d077e31e", [:mix], [], "hexpm", "7135db93b861d9e76821039b60b00a6a22d2c4e751bf8c444bffe7a042f1abaf"}, "ex_secp256k1": {:hex, :ex_secp256k1, "0.8.0", "aade42e790638de82a2b951a83f55b9cc6545b8c105b20a0112ff6b78e1800cd", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "87257ef7110c45ac396a110eb93023371db96b6f65ed0676046a11e866d1e3a0"}, From fd406a113ea78b6a4e7aecc78e4b0db04c60c274 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Thu, 13 Aug 2026 13:16:49 +0300 Subject: [PATCH 06/18] perf: reuse HTTP connections and parallelize microservice preloads (#14689) --- .../controllers/api/v2/address_controller.ex | 27 ++- .../controllers/api/v2/block_controller.ex | 19 +- .../api/v2/ethereum/deposit_controller.ex | 5 +- .../api/v2/main_page_controller.ex | 11 +- .../controllers/api/v2/mud_controller.ex | 6 +- .../controllers/api/v2/token_controller.ex | 19 +- .../api/v2/token_transfer_controller.ex | 8 +- .../api/v2/transaction_controller.ex | 53 +++-- .../api/v2/withdrawal_controller.ex | 5 +- .../api/v2/transaction_controller_test.exs | 46 +++++ apps/explorer/lib/explorer/application.ex | 103 +++++----- .../chain/address/metadata_preloader.ex | 152 ++++++++++++-- .../explorer/microservice_interfaces/bens.ex | 32 ++- .../microservice_interfaces/http_client.ex | 110 +++++++++++ .../microservice_interfaces/metadata.ex | 26 ++- apps/explorer/mix.exs | 2 + .../chain/address/metadata_preloader_test.exs | 185 ++++++++++++++++++ .../http_client_test.exs | 77 ++++++++ config/runtime.exs | 3 +- docker-compose/envs/common-blockscout.env | 1 + mix.lock | 2 +- 21 files changed, 722 insertions(+), 170 deletions(-) create mode 100644 apps/explorer/lib/explorer/microservice_interfaces/http_client.ex create mode 100644 apps/explorer/test/explorer/chain/address/metadata_preloader_test.exs create mode 100644 apps/explorer/test/explorer/microservice_interfaces/http_client_test.exs 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 640b82feba6..5f9118b36fe 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 @@ -28,15 +28,11 @@ defmodule BlockScoutWeb.API.V2.AddressController do import Explorer.Helper, only: [safe_parse_non_negative_integer: 1] - import Explorer.MicroserviceInterfaces.BENS, - only: [ - maybe_preload_ens: 1, - maybe_preload_ens_for_token_transfers: 1, - maybe_preload_ens_for_transactions: 1, - maybe_preload_ens_to_address: 1 - ] + import Explorer.MicroserviceInterfaces.BENS, only: [maybe_preload_ens_to_address: 1] + + import Explorer.Chain.Address.MetadataPreloader, + only: [maybe_preload_ens_and_metadata: 1, maybe_preload_ens_and_metadata: 2] - import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] import Explorer.Chain.Address.Reputation, only: [reputation_association: 0] alias BlockScoutWeb.AccessHelper @@ -424,7 +420,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do |> put_status(200) |> put_view(TransactionView) |> render(:transactions, %{ - transactions: transactions |> maybe_preload_ens_for_transactions() |> maybe_preload_metadata(), + transactions: transactions |> maybe_preload_ens_and_metadata(:transactions), next_page_params: next_page_params }) @@ -554,8 +550,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do token_transfers: token_transfers |> Instance.preload_nft(@api_true) - |> maybe_preload_ens_for_token_transfers() - |> maybe_preload_metadata(), + |> maybe_preload_ens_and_metadata(:token_transfers), next_page_params: next_page_params }) @@ -636,7 +631,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do |> put_status(200) |> put_view(TransactionView) |> render(:internal_transactions, %{ - internal_transactions: internal_transactions |> maybe_preload_ens() |> maybe_preload_metadata(), + internal_transactions: internal_transactions |> maybe_preload_ens_and_metadata(), next_page_params: next_page_params }) @@ -711,7 +706,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do |> put_status(200) |> put_view(TransactionView) |> render(:logs, %{ - logs: logs |> maybe_preload_ens() |> maybe_preload_metadata(), + logs: logs |> maybe_preload_ens_and_metadata(), next_page_params: next_page_params }) @@ -1034,7 +1029,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do |> put_status(200) |> put_view(WithdrawalView) |> render(:withdrawals, %{ - withdrawals: withdrawals |> maybe_preload_ens() |> maybe_preload_metadata(), + withdrawals: withdrawals |> maybe_preload_ens_and_metadata(), next_page_params: next_page_params }) @@ -1109,7 +1104,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do conn |> put_status(200) |> render(:addresses, %{ - addresses: addresses |> maybe_preload_ens() |> maybe_preload_metadata(), + addresses: addresses |> maybe_preload_ens_and_metadata(), next_page_params: next_page_params, exchange_rate: exchange_rate, total_supply: total_supply @@ -1550,7 +1545,7 @@ defmodule BlockScoutWeb.API.V2.AddressController do |> put_status(200) |> put_view(DepositView) |> render(:deposits, %{ - deposits: deposits |> maybe_preload_ens() |> maybe_preload_metadata(), + deposits: deposits |> maybe_preload_ens_and_metadata(), next_page_params: next_page_params }) end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/block_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/block_controller.ex index e9ad8868b23..d5258d0d0da 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/block_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/block_controller.ex @@ -28,10 +28,9 @@ defmodule BlockScoutWeb.API.V2.BlockController do internal_transaction_call_type_options: 1 ] - import Explorer.MicroserviceInterfaces.BENS, - only: [maybe_preload_ens: 1, maybe_preload_ens_for_blocks: 1, maybe_preload_ens_for_transactions: 1] + import Explorer.Chain.Address.MetadataPreloader, + only: [maybe_preload_ens_and_metadata: 1, maybe_preload_ens_and_metadata: 2] - import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] import Explorer.Chain.Address.Reputation, only: [reputation_association: 0] alias BlockScoutWeb.API.V2.{ @@ -227,7 +226,7 @@ defmodule BlockScoutWeb.API.V2.BlockController do conn |> put_status(200) |> render(:blocks, %{ - blocks: blocks |> maybe_preload_ens_for_blocks() |> maybe_preload_metadata(), + blocks: blocks |> maybe_preload_ens_and_metadata(:blocks), next_page_params: next_page_params }) end @@ -274,7 +273,7 @@ defmodule BlockScoutWeb.API.V2.BlockController do conn |> put_status(200) |> render(:blocks, %{ - blocks: blocks |> maybe_preload_ens_for_blocks() |> maybe_preload_metadata(), + blocks: blocks |> maybe_preload_ens_and_metadata(:blocks), next_page_params: next_page_params }) end @@ -322,7 +321,7 @@ defmodule BlockScoutWeb.API.V2.BlockController do conn |> put_status(200) |> render(:blocks, %{ - blocks: blocks |> maybe_preload_ens_for_blocks() |> maybe_preload_metadata(), + blocks: blocks |> maybe_preload_ens_and_metadata(:blocks), next_page_params: next_page_params }) end @@ -370,7 +369,7 @@ defmodule BlockScoutWeb.API.V2.BlockController do conn |> put_status(200) |> render(:blocks, %{ - blocks: blocks |> maybe_preload_ens_for_blocks() |> maybe_preload_metadata(), + blocks: blocks |> maybe_preload_ens_and_metadata(:blocks), next_page_params: next_page_params }) end @@ -424,7 +423,7 @@ defmodule BlockScoutWeb.API.V2.BlockController do |> put_status(200) |> put_view(TransactionView) |> render(:transactions, %{ - transactions: transactions |> maybe_preload_ens_for_transactions() |> maybe_preload_metadata(), + transactions: transactions |> maybe_preload_ens_and_metadata(:transactions), next_page_params: next_page_params }) end @@ -546,7 +545,7 @@ defmodule BlockScoutWeb.API.V2.BlockController do |> put_status(200) |> put_view(WithdrawalView) |> render(:withdrawals, %{ - withdrawals: withdrawals |> maybe_preload_ens() |> maybe_preload_metadata(), + withdrawals: withdrawals |> maybe_preload_ens_and_metadata(), next_page_params: next_page_params }) end @@ -684,7 +683,7 @@ defmodule BlockScoutWeb.API.V2.BlockController do |> put_status(200) |> put_view(DepositView) |> render(:deposits, %{ - deposits: deposits |> maybe_preload_ens() |> maybe_preload_metadata(), + deposits: deposits |> maybe_preload_ens_and_metadata(), next_page_params: next_page_params }) end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/ethereum/deposit_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/ethereum/deposit_controller.ex index 827e23ae7ab..1298cfd76d1 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/ethereum/deposit_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/ethereum/deposit_controller.ex @@ -4,8 +4,7 @@ defmodule BlockScoutWeb.API.V2.Ethereum.DepositController do use OpenApiSpex.ControllerSpecs import BlockScoutWeb.Chain, only: [next_page_params: 5, split_list_by_page: 1] - import Explorer.MicroserviceInterfaces.BENS, only: [maybe_preload_ens: 1] - import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] + import Explorer.Chain.Address.MetadataPreloader, only: [maybe_preload_ens_and_metadata: 1] alias Explorer.{Chain, PagingOptions} alias Explorer.Chain.Beacon.Deposit @@ -74,7 +73,7 @@ defmodule BlockScoutWeb.API.V2.Ethereum.DepositController do conn |> put_status(200) |> render(:deposits, %{ - deposits: deposits |> maybe_preload_ens() |> maybe_preload_metadata(), + deposits: deposits |> maybe_preload_ens_and_metadata(), next_page_params: next_page_params }) end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/main_page_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/main_page_controller.ex index 151528db3da..b79d0c2f9c3 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/main_page_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/main_page_controller.ex @@ -14,10 +14,7 @@ defmodule BlockScoutWeb.API.V2.MainPageController do import BlockScoutWeb.Account.AuthController, only: [current_user: 1] - import Explorer.MicroserviceInterfaces.BENS, - only: [maybe_preload_ens_for_blocks: 1, maybe_preload_ens_for_transactions: 1] - - import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] + import Explorer.Chain.Address.MetadataPreloader, only: [maybe_preload_ens_and_metadata: 2] import Explorer.Chain.Address.Reputation, only: [reputation_association: 0] case @chain_identity do @@ -81,7 +78,7 @@ defmodule BlockScoutWeb.API.V2.MainPageController do conn |> put_status(200) |> put_view(BlockView) - |> render(:blocks, %{blocks: blocks |> maybe_preload_ens_for_blocks() |> maybe_preload_metadata()}) + |> render(:blocks, %{blocks: blocks |> maybe_preload_ens_and_metadata(:blocks)}) end operation :transactions, @@ -110,7 +107,7 @@ defmodule BlockScoutWeb.API.V2.MainPageController do |> put_status(200) |> put_view(TransactionView) |> render(:transactions, %{ - transactions: recent_transactions |> maybe_preload_ens_for_transactions() |> maybe_preload_metadata() + transactions: recent_transactions |> maybe_preload_ens_and_metadata(:transactions) }) end @@ -142,7 +139,7 @@ defmodule BlockScoutWeb.API.V2.MainPageController do |> put_status(200) |> put_view(TransactionView) |> render(:transactions_watchlist, %{ - transactions: transactions |> maybe_preload_ens_for_transactions() |> maybe_preload_metadata(), + transactions: transactions |> maybe_preload_ens_and_metadata(:transactions), watchlist_names: watchlist_names }) end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/mud_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/mud_controller.ex index 60a4886dbc2..9d79b53d57c 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/mud_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/mud_controller.ex @@ -12,8 +12,7 @@ defmodule BlockScoutWeb.API.V2.MudController do import BlockScoutWeb.PagingHelper, only: [mud_records_sorting: 1] import Explorer.PagingOptions, only: [default_paging_options: 0] - import Explorer.MicroserviceInterfaces.BENS, only: [maybe_preload_ens: 1] - import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] + import Explorer.Chain.Address.MetadataPreloader, only: [maybe_preload_ens_and_metadata: 1] alias BlockScoutWeb.Schemas.Helper, as: SchemasHelper alias Explorer.Chain @@ -83,8 +82,7 @@ defmodule BlockScoutWeb.API.V2.MudController do worlds: worlds |> Enum.map(fn world -> Map.get(world_addresses, world, %Address{hash: world}) end) - |> maybe_preload_ens() - |> maybe_preload_metadata(), + |> maybe_preload_ens_and_metadata(), next_page_params: next_page_params }) end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/token_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/token_controller.ex index f9e5924bbcd..e140b16d8fa 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/token_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/token_controller.ex @@ -35,10 +35,9 @@ defmodule BlockScoutWeb.API.V2.TokenController do tokens_sorting: 1 ] - import Explorer.MicroserviceInterfaces.BENS, - only: [maybe_preload_ens: 1, maybe_preload_ens_for_token_transfers: 1] + import Explorer.Chain.Address.MetadataPreloader, + only: [maybe_preload_ens_and_metadata: 1, maybe_preload_ens_and_metadata: 2] - import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] import Explorer.PagingOptions, only: [default_paging_options: 0] action_fallback(BlockScoutWeb.API.V2.FallbackController) @@ -186,8 +185,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do token_transfers: token_transfers |> Instance.preload_nft(@api_true) - |> maybe_preload_ens_for_token_transfers() - |> maybe_preload_metadata(), + |> maybe_preload_ens_and_metadata(:token_transfers), next_page_params: next_page_params }) end @@ -234,7 +232,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do conn |> put_status(200) |> render(:token_holders, %{ - token_balances: token_balances |> maybe_preload_ens() |> maybe_preload_metadata(), + token_balances: token_balances |> maybe_preload_ens_and_metadata(), next_page_params: next_page_params }) end @@ -304,8 +302,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do token_instances: token_instances |> put_owner(holder_address_with_proxy_implementations, holder_address_hash) - |> maybe_preload_ens() - |> maybe_preload_metadata(), + |> maybe_preload_ens_and_metadata(), next_page_params: next_page_params, token: token }) @@ -331,7 +328,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do conn |> put_status(200) |> render(:token_instances, %{ - token_instances: token_instances |> maybe_preload_ens() |> maybe_preload_metadata(), + token_instances: token_instances |> maybe_preload_ens_and_metadata(), next_page_params: next_page_params, token: token }) @@ -445,7 +442,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do |> put_status(200) |> put_view(TransactionView) |> render(:token_transfers, %{ - token_transfers: token_transfers |> maybe_preload_ens_for_token_transfers() |> maybe_preload_metadata(), + token_transfers: token_transfers |> maybe_preload_ens_and_metadata(:token_transfers), next_page_params: next_page_params }) end @@ -503,7 +500,7 @@ defmodule BlockScoutWeb.API.V2.TokenController do conn |> put_status(200) |> render(:token_holders, %{ - token_balances: token_holders |> maybe_preload_ens() |> maybe_preload_metadata(), + token_balances: token_holders |> maybe_preload_ens_and_metadata(), next_page_params: next_page_params }) end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/token_transfer_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/token_transfer_controller.ex index 71dff53a195..57f2c6929d5 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/token_transfer_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/token_transfer_controller.ex @@ -18,10 +18,7 @@ defmodule BlockScoutWeb.API.V2.TokenTransferController do token_transfers_types_options: 1 ] - import Explorer.MicroserviceInterfaces.BENS, - only: [maybe_preload_ens_for_token_transfers: 1] - - import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] + import Explorer.Chain.Address.MetadataPreloader, only: [maybe_preload_ens_and_metadata: 2] import Explorer.PagingOptions, only: [default_paging_options: 0] alias Explorer.Chain.Token.Instance @@ -110,8 +107,7 @@ defmodule BlockScoutWeb.API.V2.TokenTransferController do token_transfers: token_transfers |> Instance.preload_nft(@api_true) - |> maybe_preload_ens_for_token_transfers() - |> maybe_preload_metadata(), + |> maybe_preload_ens_and_metadata(:token_transfers), decoded_transactions_map: decoded_transactions_map, next_page_params: next_page_params }) 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 2aea01ce30a..a79a0007a50 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 @@ -31,17 +31,13 @@ defmodule BlockScoutWeb.API.V2.TransactionController do type_filter_options: 1 ] - import Explorer.MicroserviceInterfaces.BENS, + import Explorer.Chain.Address.MetadataPreloader, only: [ - maybe_preload_ens: 1, - maybe_preload_ens_for_token_transfers: 1, - maybe_preload_ens_for_transactions: 1, - maybe_preload_ens_to_transaction: 1 + maybe_preload_ens_and_metadata: 1, + maybe_preload_ens_and_metadata: 2, + maybe_preload_selected_meta: 2 ] - import Explorer.MicroserviceInterfaces.Metadata, - only: [maybe_preload_metadata: 1, maybe_preload_metadata_to_transaction: 1] - import Explorer.Chain.Address.Reputation, only: [reputation_association: 0] import Ecto.Query, @@ -221,8 +217,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do transaction: preloaded |> Instance.preload_nft(@api_true) - |> maybe_preload_ens_to_transaction() - |> maybe_preload_metadata_to_transaction() + |> maybe_preload_ens_and_metadata() }) end end @@ -273,7 +268,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do conn |> put_status(200) |> render(:transactions, %{ - transactions: transactions |> maybe_preload_ens_for_transactions() |> maybe_preload_metadata(), + transactions: transactions |> maybe_preload_ens_and_metadata(:transactions), next_page_params: next_page_params }) end @@ -517,7 +512,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do conn |> put_status(200) |> render(:transactions, %{ - transactions: transactions |> maybe_preload_ens_for_transactions() |> maybe_preload_metadata(), + transactions: transactions |> maybe_preload_ens_and_metadata(:transactions), next_page_params: next_page_params }) end @@ -561,7 +556,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do conn |> put_status(200) |> render(:transactions, %{ - transactions: transactions |> maybe_preload_ens_for_transactions() |> maybe_preload_metadata(), + transactions: transactions |> maybe_preload_ens_and_metadata(:transactions), next_page_params: next_page_params }) end @@ -608,7 +603,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do conn |> put_status(200) |> render(:transactions, %{ - transactions: transactions |> maybe_preload_ens_for_transactions() |> maybe_preload_metadata(), + transactions: transactions |> maybe_preload_ens_and_metadata(:transactions), next_page_params: next_page_params }) end @@ -718,8 +713,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do token_transfers: token_transfers |> Instance.preload_nft(@api_true) - |> maybe_preload_ens_for_token_transfers() - |> maybe_preload_metadata(), + |> maybe_preload_ens_and_metadata(:token_transfers), next_page_params: next_page_params }) end @@ -770,7 +764,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do conn |> put_status(200) |> render(:internal_transactions, %{ - internal_transactions: internal_transactions |> maybe_preload_ens() |> maybe_preload_metadata(), + internal_transactions: internal_transactions |> maybe_preload_ens_and_metadata(), next_page_params: next_page_params }) end @@ -826,7 +820,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do |> put_status(200) |> render(:logs, %{ transaction_hash: transaction_hash, - logs: logs |> maybe_preload_ens() |> maybe_preload_metadata(), + logs: logs |> maybe_preload_ens_and_metadata(), next_page_params: next_page_params }) end @@ -968,7 +962,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do conn |> put_status(200) |> render(:transactions_watchlist, %{ - transactions: transactions |> maybe_preload_ens_for_transactions() |> maybe_preload_metadata(), + transactions: transactions |> maybe_preload_ens_and_metadata(:transactions), next_page_params: next_page_params, watchlist_names: watchlist_names }) @@ -1095,10 +1089,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do |> Keyword.merge(@api_true) with {:ok, transaction, _transaction_hash} <- validate_transaction(transaction_hash_string, params, options) do - preloaded = - transaction - |> maybe_preload_preview_ens(params) - |> maybe_preload_preview_metadata(params) + preloaded = maybe_preload_selected_meta(transaction, requested_preview_meta_fields(params)) conn |> put_status(200) @@ -1106,13 +1097,13 @@ defmodule BlockScoutWeb.API.V2.TransactionController do end end - defp maybe_preload_preview_ens(transaction, %{preload_ens: true}), do: maybe_preload_ens_to_transaction(transaction) - defp maybe_preload_preview_ens(transaction, _params), do: transaction - - defp maybe_preload_preview_metadata(transaction, %{preload_metadata: true}), - do: maybe_preload_metadata_to_transaction(transaction) - - defp maybe_preload_preview_metadata(transaction, _params), do: transaction + # Both preloads are opt-in per request, so only what the caller asked for is + # fetched. When both are requested they are fetched concurrently. + defp requested_preview_meta_fields(params) do + for {param, field} <- [preload_ens: :ens_domain_name, preload_metadata: :metadata], + params[param] == true, + do: field + end operation :blobs, summary: "List blobs for a transaction", @@ -1273,7 +1264,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do |> put_status(200) |> put_view(DepositView) |> render(:deposits, %{ - deposits: deposits |> maybe_preload_ens() |> maybe_preload_metadata(), + deposits: deposits |> maybe_preload_ens_and_metadata(), next_page_params: next_page_params }) end diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/withdrawal_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/withdrawal_controller.ex index 3b32c056a80..b9c2715e166 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/withdrawal_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/withdrawal_controller.ex @@ -6,8 +6,7 @@ defmodule BlockScoutWeb.API.V2.WithdrawalController do import BlockScoutWeb.Chain, only: [paging_options: 1, next_page_params: 3, split_list_by_page: 1] - import Explorer.MicroserviceInterfaces.BENS, only: [maybe_preload_ens: 1] - import Explorer.MicroserviceInterfaces.Metadata, only: [maybe_preload_metadata: 1] + import Explorer.Chain.Address.MetadataPreloader, only: [maybe_preload_ens_and_metadata: 1] alias Explorer.Chain alias Explorer.Chain.Withdrawal @@ -53,7 +52,7 @@ defmodule BlockScoutWeb.API.V2.WithdrawalController do conn |> put_status(200) |> render(:withdrawals, %{ - withdrawals: withdrawals |> maybe_preload_ens() |> maybe_preload_metadata(), + withdrawals: withdrawals |> maybe_preload_ens_and_metadata(), next_page_params: next_page_params }) end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs index af47396f174..f6fd273280d 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs @@ -3498,4 +3498,50 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do end end end + + describe "/transactions/{transaction_hash}/preview" do + setup do + bypass = Bypass.open() + old_bens_env = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.BENS, []) + old_chain_id = Application.get_env(:block_scout_web, :chain_id) + + Application.put_env(:block_scout_web, :chain_id, 1) + + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, old_bens_env) + Application.put_env(:block_scout_web, :chain_id, old_chain_id) + end) + + {:ok, bypass: bypass} + end + + test "preloads ENS names when preload_ens is requested", %{conn: conn, bypass: bypass} do + transaction = :transaction |> insert() |> with_block(status: :ok) + from_hash = Address.checksum(transaction.from_address_hash) + + Bypass.expect_once(bypass, "POST", "/api/v1/1/addresses:batch_resolve_names", fn conn -> + Plug.Conn.resp(conn, 200, Jason.encode!(%{"names" => %{from_hash => "preview.eth"}})) + end) + + request = + get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/preview", %{"preload_ens" => "true"}) + + assert %{"from" => %{"ens_domain_name" => "preview.eth"}} = json_response(request, 200) + end + + test "queries no microservice when neither preload is requested", %{conn: conn} do + transaction = :transaction |> insert() |> with_block(status: :ok) + + # any request to the bypass would fail the test, since nothing is expected + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/preview") + + assert %{"from" => %{"ens_domain_name" => nil}} = json_response(request, 200) + end + end end diff --git a/apps/explorer/lib/explorer/application.ex b/apps/explorer/lib/explorer/application.ex index 50e8ee90a26..57372668c08 100644 --- a/apps/explorer/lib/explorer/application.ex +++ b/apps/explorer/lib/explorer/application.ex @@ -36,7 +36,7 @@ defmodule Explorer.Application do alias Explorer.Chain.Supply.RSK alias Explorer.Market.MarketHistoryCache - alias Explorer.MicroserviceInterfaces.MultichainSearch + alias Explorer.MicroserviceInterfaces.{HttpClient, MultichainSearch} alias Explorer.Prometheus.Instrumenter alias Explorer.Repo.PrometheusLogger alias Explorer.Stats.HotSmartContractsCache @@ -61,54 +61,59 @@ defmodule Explorer.Application do ) # Children to start in all environments - base_children = [ - Explorer.Repo, - Explorer.Repo.Replica1, - Explorer.Vault, - Supervisor.child_spec({SpandexDatadog.ApiServer, datadog_opts()}, id: SpandexDatadog.ApiServer), - Supervisor.child_spec({Task.Supervisor, name: Explorer.HistoryTaskSupervisor}, - id: Explorer.HistoryTaskSupervisor - ), - Supervisor.child_spec({Task.Supervisor, name: Explorer.MarketTaskSupervisor}, id: Explorer.MarketTaskSupervisor), - Supervisor.child_spec({Task.Supervisor, name: Explorer.GenesisDataTaskSupervisor}, id: GenesisDataTaskSupervisor), - Supervisor.child_spec({Task.Supervisor, name: Explorer.TaskSupervisor}, id: Explorer.TaskSupervisor), - Supervisor.child_spec({Task.Supervisor, name: Explorer.LookUpSmartContractSourcesTaskSupervisor}, - id: LookUpSmartContractSourcesTaskSupervisor - ), - Supervisor.child_spec({Task.Supervisor, name: Explorer.WETHMigratorSupervisor}, id: WETHMigratorSupervisor), - {Registry, keys: :duplicate, name: Registry.ChainEvents, id: Registry.ChainEvents}, - Accounts, - AddressesCoinBalanceSum, - AddressesCoinBalanceSumMinusBurnt, - BackgroundMigrations, - BlocksCount, - BlockNumber, - Blocks, - ChainId, - GasPriceOracle, - GasUsageSum, - PendingBlockOperationCount, - PendingTransactionOperationCount, - TransactionsCount, - StateChanges, - Transactions, - Uncles, - AddressTabsElementsCount, - con_cache_child_spec(MarketHistoryCache.cache_name()), - con_cache_child_spec(HotSmartContractsCache.cache_name(), - ttl_check_interval: :timer.seconds(1), - global_ttl: :infinity - ), - con_cache_child_spec(RSK.cache_name(), ttl_check_interval: :timer.minutes(1), global_ttl: :timer.minutes(30)), - {Redix, redix_opts()}, - {Explorer.Utility.ReplicaAccessibilityManager, []}, - :hackney_pool.child_spec(:default, - recv_timeout: 60_000, - timeout: 60_000, - max_connections: Application.get_env(:explorer, :hackney_default_pool_size) - ), - Explorer.Promo.Autoscout - ] + base_children = + [ + Explorer.Repo, + Explorer.Repo.Replica1, + Explorer.Vault, + Supervisor.child_spec({SpandexDatadog.ApiServer, datadog_opts()}, id: SpandexDatadog.ApiServer), + Supervisor.child_spec({Task.Supervisor, name: Explorer.HistoryTaskSupervisor}, + id: Explorer.HistoryTaskSupervisor + ), + Supervisor.child_spec({Task.Supervisor, name: Explorer.MarketTaskSupervisor}, + id: Explorer.MarketTaskSupervisor + ), + Supervisor.child_spec({Task.Supervisor, name: Explorer.GenesisDataTaskSupervisor}, + id: GenesisDataTaskSupervisor + ), + Supervisor.child_spec({Task.Supervisor, name: Explorer.TaskSupervisor}, id: Explorer.TaskSupervisor), + Supervisor.child_spec({Task.Supervisor, name: Explorer.LookUpSmartContractSourcesTaskSupervisor}, + id: LookUpSmartContractSourcesTaskSupervisor + ), + Supervisor.child_spec({Task.Supervisor, name: Explorer.WETHMigratorSupervisor}, id: WETHMigratorSupervisor), + {Registry, keys: :duplicate, name: Registry.ChainEvents, id: Registry.ChainEvents}, + Accounts, + AddressesCoinBalanceSum, + AddressesCoinBalanceSumMinusBurnt, + BackgroundMigrations, + BlocksCount, + BlockNumber, + Blocks, + ChainId, + GasPriceOracle, + GasUsageSum, + PendingBlockOperationCount, + PendingTransactionOperationCount, + TransactionsCount, + StateChanges, + Transactions, + Uncles, + AddressTabsElementsCount, + con_cache_child_spec(MarketHistoryCache.cache_name()), + con_cache_child_spec(HotSmartContractsCache.cache_name(), + ttl_check_interval: :timer.seconds(1), + global_ttl: :infinity + ), + con_cache_child_spec(RSK.cache_name(), ttl_check_interval: :timer.minutes(1), global_ttl: :timer.minutes(30)), + {Redix, redix_opts()}, + {Explorer.Utility.ReplicaAccessibilityManager, []}, + :hackney_pool.child_spec(:default, + recv_timeout: 60_000, + timeout: 60_000, + max_connections: Application.get_env(:explorer, :hackney_default_pool_size) + ), + Explorer.Promo.Autoscout + ] ++ HttpClient.pool_child_specs() children = base_children ++ configurable_children() diff --git a/apps/explorer/lib/explorer/chain/address/metadata_preloader.ex b/apps/explorer/lib/explorer/chain/address/metadata_preloader.ex index ea8291cbba6..85dad4aead6 100644 --- a/apps/explorer/lib/explorer/chain/address/metadata_preloader.ex +++ b/apps/explorer/lib/explorer/chain/address/metadata_preloader.ex @@ -33,6 +33,74 @@ defmodule Explorer.Chain.Address.MetadataPreloader do @type supported_input :: [supported_types] | supported_types + @typedoc """ + Kind of entity being preloaded, used to pick the `DISABLE_*_BENS_PRELOAD` flag + that applies to it. `:any` means that no flag applies. + """ + @type entity_kind :: :any | :blocks | :token_transfers | :transactions + + @typedoc """ + Field a microservice preload writes to: `:ens_domain_name` is served by BENS, + `:metadata` by the Metadata microservice. + """ + @type meta_field :: :ens_domain_name | :metadata + + @all_meta_fields [:ens_domain_name, :metadata] + + # Backstop for the concurrent microservice requests. Both BENS and Metadata + # enforce their own receive timeout, so this only fires if a request hangs + # outside of it. + @concurrent_preload_timeout :timer.seconds(10) + + @doc """ + Preloads ENS names and metadata to supported entities, querying the BENS and + Metadata microservices concurrently. + + Both microservices are asked about the same set of address hashes, so their + requests are independent and there is nothing to gain from issuing them one + after another: sequentially the entity waits for the sum of both round trips, + concurrently only for the slower one. + + `entity_kind` selects the `DISABLE_*_BENS_PRELOAD` flag that applies to the + input. The metadata preload is not flag-gated. + """ + @spec maybe_preload_ens_and_metadata(supported_input(), entity_kind()) :: supported_input() + def maybe_preload_ens_and_metadata(input, entity_kind \\ :any) do + fields = + if BENS.ens_preload_disabled?(entity_kind), + do: @all_meta_fields -- [:ens_domain_name], + else: @all_meta_fields + + maybe_preload_selected_meta(input, fields) + end + + @doc """ + Preloads only the requested `fields` to supported entities, querying the + microservices that serve them concurrently. + + Use this where the caller decides per request which preloads to pay for, such + as the `preload_ens` and `preload_metadata` parameters of the transaction + preview endpoint. Unlike `maybe_preload_ens_and_metadata/2`, no + `DISABLE_*_BENS_PRELOAD` flag is consulted: those flags exist to keep the + latency out of list endpoints that always preload, whereas here nothing is + requested unless the caller asks for it. + """ + @spec maybe_preload_selected_meta(supported_input(), [meta_field()]) :: supported_input() + def maybe_preload_selected_meta(input, fields) + + def maybe_preload_selected_meta(input, []), do: input + + def maybe_preload_selected_meta(nil, _fields), do: nil + + def maybe_preload_selected_meta(items, fields) when is_list(items) do + preload_selected_meta(items, fields) + end + + def maybe_preload_selected_meta(item, fields) do + [item_with_meta] = preload_selected_meta([item], fields) + item_with_meta + end + @doc """ Preloads ENS/metadata to supported entities """ @@ -77,13 +145,7 @@ defmodule Explorer.Chain.Address.MetadataPreloader do """ @spec preload_ens_to_list([supported_types]) :: [supported_types] def preload_ens_to_list(items) do - address_hash_strings = - items - |> Enum.reduce([], fn item, acc -> - item_to_address_hash_strings(item) ++ acc - end) - |> Enum.filter(&(&1 != "")) - |> Enum.uniq() + address_hash_strings = address_hash_strings(items) case BENS.ens_names_batch_request(address_hash_strings) do {:ok, result} -> @@ -99,11 +161,7 @@ defmodule Explorer.Chain.Address.MetadataPreloader do """ @spec preload_metadata_to_list([supported_types]) :: [supported_types] def preload_metadata_to_list(items) do - address_hash_strings = - items - |> Enum.flat_map(&item_to_address_hash_strings/1) - |> Enum.filter(&(&1 != "")) - |> Enum.uniq() + address_hash_strings = address_hash_strings(items) case Metadata.get_addresses_tags(address_hash_strings) do {:ok, result} -> @@ -150,6 +208,76 @@ defmodule Explorer.Chain.Address.MetadataPreloader do end) end + defp preload_selected_meta(items, fields) do + case address_hash_strings(items) do + [] -> + items + + address_hash_strings -> + address_hash_strings + |> meta_fetchers(fields) + |> fetch_meta() + |> Enum.reduce(items, fn {field, meta}, acc -> put_meta_to_items(acc, meta, field) end) + end + end + + defp address_hash_strings(items) do + items + |> Enum.flat_map(&item_to_address_hash_strings/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + + defp meta_fetchers(address_hash_strings, fields) do + [ + {:ens_domain_name, BENS.enabled?(), fn -> ens_names(address_hash_strings) end}, + {:metadata, Metadata.enabled?(), fn -> metadata_tags(address_hash_strings) end} + ] + |> Enum.filter(fn {field, enabled?, _fetcher} -> enabled? and field in fields end) + end + + defp fetch_meta([]), do: [] + + # A single request needs no task: running it in the caller keeps the logger + # metadata and the stacktrace of the calling process. + defp fetch_meta([{field, _enabled?, fetcher}]), do: fetched_meta(field, fetcher.()) + + defp fetch_meta(fetchers) do + Explorer.TaskSupervisor + |> Task.Supervisor.async_stream_nolink( + fetchers, + fn {field, _enabled?, fetcher} -> {field, fetcher.()} end, + timeout: @concurrent_preload_timeout, + on_timeout: :kill_task, + ordered: false + ) + |> Enum.flat_map(fn + {:ok, {field, result}} -> fetched_meta(field, result) + _other -> [] + end) + end + + defp fetched_meta(field, {:ok, meta}), do: [{field, meta || %{}}] + defp fetched_meta(_field, _error), do: [] + + defp ens_names(address_hash_strings) do + case BENS.ens_names_batch_request(address_hash_strings) do + {:ok, result} -> {:ok, result["names"]} + _error -> :error + end + end + + defp metadata_tags(address_hash_strings) do + case Metadata.get_addresses_tags(address_hash_strings) do + {:ok, result} -> {:ok, result["addresses"]} + _error -> :error + end + end + + defp put_meta_to_items(items, meta, field) do + Enum.map(items, &put_meta_to_item(&1, meta, field)) + end + defp item_to_address_hash_strings(nil), do: [] defp item_to_address_hash_strings(%Transaction{ diff --git a/apps/explorer/lib/explorer/microservice_interfaces/bens.ex b/apps/explorer/lib/explorer/microservice_interfaces/bens.ex index e63da5a6d2b..90f0c5913af 100644 --- a/apps/explorer/lib/explorer/microservice_interfaces/bens.ex +++ b/apps/explorer/lib/explorer/microservice_interfaces/bens.ex @@ -4,18 +4,23 @@ defmodule Explorer.MicroserviceInterfaces.BENS do Interface to interact with Blockscout ENS microservice """ - alias Explorer.{Chain, HttpClient} + alias Explorer.Chain alias Explorer.Chain.Address.MetadataPreloader alias Explorer.Chain.{Address, Block, Transaction} + alias Explorer.MicroserviceInterfaces.HttpClient alias Explorer.Utility.Microservice require Logger import Explorer.Chain.Address.MetadataPreloader, only: [maybe_preload_meta: 3] - @post_timeout :timer.seconds(5) + # The batch resolve is only used to decorate API responses with ENS names, so + # it is kept short on purpose: a slow BENS should cost the response its names, + # not its latency. A longer timeout also keeps a pooled connection held for + # longer, which multiplies into pool pressure under load. + @preload_timeout :timer.seconds(1) @request_error_msg "Error while sending request to BENS microservice" @doc """ @@ -115,7 +120,7 @@ defmodule Explorer.MicroserviceInterfaces.BENS do defp http_post_request(url, body) do headers = [{"Content-Type", "application/json"}] - case HttpClient.post(url, Jason.encode!(body), headers, recv_timeout: @post_timeout) do + case HttpClient.post(url, Jason.encode!(body), headers, recv_timeout: @preload_timeout) do {:ok, %{body: body, status_code: 200}} -> Jason.decode(body) @@ -361,20 +366,33 @@ defmodule Explorer.MicroserviceInterfaces.BENS do @spec maybe_preload_ens_for_blocks(MetadataPreloader.supported_input()) :: MetadataPreloader.supported_input() def maybe_preload_ens_for_blocks(blocks) do - if Application.get_env(:explorer, __MODULE__, [])[:disable_blocks_bens_preload] do + if ens_preload_disabled?(:blocks) do blocks else maybe_preload_ens(blocks) end end + @doc """ + Returns `true` if the ENS preload is disabled for the given entity kind via the + corresponding `DISABLE_*_BENS_PRELOAD` environment variable. + """ + @spec ens_preload_disabled?(MetadataPreloader.entity_kind()) :: boolean() + def ens_preload_disabled?(:blocks), do: !!config()[:disable_blocks_bens_preload] + + def ens_preload_disabled?(:token_transfers), do: !!config()[:disable_token_transfers_bens_preload] + + def ens_preload_disabled?(:transactions), do: !!config()[:disable_transactions_bens_preload] + + def ens_preload_disabled?(:any), do: false + @doc """ Preloads ENS data to the list of token transfers unless disabled via DISABLE_TOKEN_TRANSFERS_BENS_PRELOAD """ @spec maybe_preload_ens_for_token_transfers(MetadataPreloader.supported_input()) :: MetadataPreloader.supported_input() def maybe_preload_ens_for_token_transfers(token_transfers) do - if Application.get_env(:explorer, __MODULE__, [])[:disable_token_transfers_bens_preload] do + if ens_preload_disabled?(:token_transfers) do token_transfers else maybe_preload_ens(token_transfers) @@ -387,10 +405,12 @@ defmodule Explorer.MicroserviceInterfaces.BENS do @spec maybe_preload_ens_for_transactions(MetadataPreloader.supported_input()) :: MetadataPreloader.supported_input() def maybe_preload_ens_for_transactions(transactions) do - if Application.get_env(:explorer, __MODULE__, [])[:disable_transactions_bens_preload] do + if ens_preload_disabled?(:transactions) do transactions else maybe_preload_ens(transactions) end end + + defp config, do: Application.get_env(:explorer, __MODULE__, []) end diff --git a/apps/explorer/lib/explorer/microservice_interfaces/http_client.ex b/apps/explorer/lib/explorer/microservice_interfaces/http_client.ex new file mode 100644 index 00000000000..198bf2caeae --- /dev/null +++ b/apps/explorer/lib/explorer/microservice_interfaces/http_client.ex @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.MicroserviceInterfaces.HttpClient do + @moduledoc """ + HTTP client for requests to Blockscout microservices. + + Microservice requests sit on the critical path of API responses and are made + over and over to the same handful of hosts. The default client configured in + `:explorer, :http_client` uses `Tesla.Adapter.Mint`, which opens a connection + for every request and closes it once the response is read, so every call pays + for DNS resolution and a TCP + TLS handshake. Measured against a microservice + answering in ~30ms at the origin, that overhead dominates the call. + + This module routes microservice requests through Finch (mint connections kept + alive in a `NimblePool`), so connections are reused across requests. hackney + was avoided deliberately: it leaked binaries in production before. The Finch + instances are started in `Explorer.Application`. + + Two Finch instances are used, because the workloads have very different + shapes: + + * `#{inspect(__MODULE__)}.Finch` for short requests on the critical path of + an API response, such as the ENS and metadata preloads. These get a short + pool (checkout) timeout: when the pool is saturated it is better to answer + without the decorative data than to make the caller queue for a + connection. + + * `#{inspect(__MODULE__)}.ProxyFinch` for requests proxied to a microservice + on behalf of an API caller, which are allowed to run for much longer (see + `MICROSERVICE_METADATA_PROXY_REQUESTS_TIMEOUT`). Sharing a pool with the + preloads would let a handful of these hold connections for tens of seconds + and starve them. + """ + + @finch_name __MODULE__.Finch + @proxy_finch_name __MODULE__.ProxyFinch + + # Milliseconds a request waits for a free connection. A preload that has to + # queue has already lost the latency it was trying to save, so it gives up + # quickly and the response is rendered without the extra data. + @checkout_timeout 500 + @proxy_checkout_timeout :timer.seconds(5) + + @doc """ + Sends a pooled GET request. Accepts the same options as `Explorer.HttpClient.get/3`. + """ + @spec get(binary(), list(), keyword()) :: {:ok, map()} | {:error, any()} + def get(url, headers \\ [], options \\ []) do + request(:get, url, nil, headers, options, @finch_name, @checkout_timeout) + end + + @doc """ + Sends a pooled POST request. Accepts the same options as `Explorer.HttpClient.post/4`. + """ + @spec post(binary(), iodata(), list(), keyword()) :: {:ok, map()} | {:error, any()} + def post(url, body, headers \\ [], options \\ []) do + request(:post, url, body, headers, options, @finch_name, @checkout_timeout) + end + + @doc """ + Sends a GET request proxied on behalf of an API caller, through the Finch + instance reserved for long-running microservice requests. + """ + @spec proxy_get(binary(), list(), keyword()) :: {:ok, map()} | {:error, any()} + def proxy_get(url, headers \\ [], options \\ []) do + request(:get, url, nil, headers, options, @proxy_finch_name, @proxy_checkout_timeout) + end + + @doc """ + Returns the child specs of the Finch instances used for microservice requests. + """ + @spec pool_child_specs() :: [Supervisor.child_spec()] + def pool_child_specs do + pools = %{ + default: [ + size: Application.get_env(:explorer, :microservice_http_pool_size), + # metrics make the pools observable in a remote console via + # Finch.get_pool_status/2, e.g. when debugging checkout timeouts + start_pool_metrics?: true + ] + } + + Enum.map([@finch_name, @proxy_finch_name], &Finch.child_spec(name: &1, pools: pools)) + end + + defp request(method, url, body, headers, options, finch_name, default_checkout_timeout) do + adapter_options = + [name: finch_name, pool_timeout: options[:checkout_timeout] || default_checkout_timeout] + |> put_receive_timeout(options[:recv_timeout]) + + [method: method, url: url, body: body, headers: headers, query: options[:params] || []] + |> then(&Tesla.request(client(adapter_options), &1)) + |> parse_response() + end + + defp client(adapter_options) do + Tesla.client([], {Tesla.Adapter.Finch, adapter_options}) + end + + defp put_receive_timeout(adapter_options, nil), do: adapter_options + + defp put_receive_timeout(adapter_options, recv_timeout) do + Keyword.put(adapter_options, :receive_timeout, recv_timeout) + end + + defp parse_response({:ok, %Tesla.Env{body: body, status: status_code, headers: headers}}) do + {:ok, %{body: body, status_code: status_code, headers: headers}} + end + + defp parse_response(error), do: error +end diff --git a/apps/explorer/lib/explorer/microservice_interfaces/metadata.ex b/apps/explorer/lib/explorer/microservice_interfaces/metadata.ex index 60576810820..c613c7ea65c 100644 --- a/apps/explorer/lib/explorer/microservice_interfaces/metadata.ex +++ b/apps/explorer/lib/explorer/microservice_interfaces/metadata.ex @@ -4,15 +4,22 @@ defmodule Explorer.MicroserviceInterfaces.Metadata do Module to interact with Metadata microservice """ - alias Explorer.{Chain, HttpClient} + alias Explorer.Chain alias Explorer.Chain.{Address.MetadataPreloader, Block, Transaction} + alias Explorer.MicroserviceInterfaces.HttpClient alias Explorer.Utility.Microservice - import Explorer.MicroserviceInterfaces.BENS, only: [maybe_preload_ens: 1] - import Explorer.Chain.Address.MetadataPreloader, only: [maybe_preload_meta: 3] + import Explorer.Chain.Address.MetadataPreloader, + only: [maybe_preload_ens_and_metadata: 1, maybe_preload_meta: 3] + import Explorer.Chain.SmartContract.Proxy.Models.Implementation, only: [proxy_implementations_association: 0] require Logger + + # Preloads only decorate API responses, so a slow Metadata service should cost + # the response its tags, not its latency. A longer timeout also keeps a pooled + # connection held for longer, which multiplies into pool pressure under load. + @preload_timeout :timer.seconds(1) @request_timeout :timer.seconds(5) @tags_per_address_limit 5 @@ -47,7 +54,7 @@ defmodule Explorer.MicroserviceInterfaces.Metadata do chain_id: Application.get_env(:block_scout_web, :chain_id) } - http_get_request(addresses_metadata_url(), params) + http_get_request(addresses_metadata_url(), params, @preload_timeout) end end @@ -93,17 +100,17 @@ defmodule Explorer.MicroserviceInterfaces.Metadata do tag_types: "protocol,name" }) - http_get_request(tags_search_url(), params, &prepare_search_results/1) + http_get_request(tags_search_url(), params, @request_timeout, &prepare_search_results/1) _ -> :disabled end end - defp http_get_request(url, params, parsing_function \\ &decode_meta/1) do + defp http_get_request(url, params, recv_timeout, parsing_function \\ &decode_meta/1) do headers = [] - case HttpClient.get(url, headers, params: params, recv_timeout: @request_timeout) do + case HttpClient.get(url, headers, params: params, recv_timeout: recv_timeout) do {:ok, %{body: body, status_code: 200}} -> body |> Jason.decode() |> parsing_function.() @@ -120,7 +127,7 @@ defmodule Explorer.MicroserviceInterfaces.Metadata do end defp http_get_request_for_proxy_method(url, params, parsing_function) do - case HttpClient.get(url, [], params: params, recv_timeout: config()[:proxy_requests_timeout]) do + case HttpClient.proxy_get(url, [], params: params, recv_timeout: config()[:proxy_requests_timeout]) do {:ok, %{body: body, status_code: 200}} -> {200, body |> Jason.decode() |> parsing_function.()} @@ -216,8 +223,7 @@ defmodule Explorer.MicroserviceInterfaces.Metadata do proxy_implementations_association() => :optional } ) - |> maybe_preload_ens() - |> maybe_preload_metadata() + |> maybe_preload_ens_and_metadata() )} end diff --git a/apps/explorer/mix.exs b/apps/explorer/mix.exs index d94edb3db66..16b17491184 100644 --- a/apps/explorer/mix.exs +++ b/apps/explorer/mix.exs @@ -137,6 +137,8 @@ defmodule Explorer.Mixfile do {:hammer, "~> 7.0"}, {:ton, "~> 0.5.0"}, {:mint, "~> 1.0"}, + # pooled HTTP client on top of mint, used for microservice requests + {:finch, "~> 0.18"}, {:oban, "~> 2.19"} ] end diff --git a/apps/explorer/test/explorer/chain/address/metadata_preloader_test.exs b/apps/explorer/test/explorer/chain/address/metadata_preloader_test.exs new file mode 100644 index 00000000000..5bbdf43b602 --- /dev/null +++ b/apps/explorer/test/explorer/chain/address/metadata_preloader_test.exs @@ -0,0 +1,185 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Address.MetadataPreloaderTest do + use ExUnit.Case, async: false + + alias Explorer.Chain + alias Explorer.Chain.Address + alias Explorer.Chain.Address.MetadataPreloader + alias Explorer.MicroserviceInterfaces.{BENS, Metadata} + alias Plug.Conn + + @address_hash_string "0x000000000000000000000000000000000000000a" + @ens_name "test.eth" + @metadata_tag %{"name" => "Test", "tagType" => "name", "meta" => %{}} + + setup do + bypass = Bypass.open() + old_bens_env = Application.get_env(:explorer, BENS, []) + old_metadata_env = Application.get_env(:explorer, Metadata, []) + old_chain_id = Application.get_env(:block_scout_web, :chain_id) + + Application.put_env(:block_scout_web, :chain_id, 1) + + Application.put_env( + :explorer, + BENS, + Keyword.merge(old_bens_env || [], + service_url: "http://localhost:#{bypass.port}", + enabled: true, + # legacy, chain-id-based URLs, so the expected paths are deterministic + protocols: [] + ) + ) + + Application.put_env( + :explorer, + Metadata, + Keyword.merge(old_metadata_env || [], service_url: "http://localhost:#{bypass.port}", enabled: true) + ) + + on_exit(fn -> + Bypass.down(bypass) + Application.put_env(:explorer, BENS, old_bens_env) + Application.put_env(:explorer, Metadata, old_metadata_env) + Application.put_env(:block_scout_web, :chain_id, old_chain_id) + end) + + {:ok, bypass: bypass} + end + + describe "maybe_preload_ens_and_metadata/2" do + test "preloads ENS names and metadata from both microservices", %{bypass: bypass} do + expect_both_microservices(bypass) + + [address] = MetadataPreloader.maybe_preload_ens_and_metadata([address()]) + + assert address.ens_domain_name == @ens_name + assert address.metadata == %{"tags" => [@metadata_tag]} + end + + test "preloads metadata when the ENS preload is disabled for the entity kind", %{bypass: bypass} do + Application.put_env( + :explorer, + BENS, + Keyword.put(Application.get_env(:explorer, BENS), :disable_transactions_bens_preload, true) + ) + + expect_both_microservices(bypass) + + [address] = MetadataPreloader.maybe_preload_ens_and_metadata([address()], :transactions) + + assert address.ens_domain_name == nil + assert address.metadata == %{"tags" => [@metadata_tag]} + end + + test "preloads ENS names when the metadata microservice fails", %{bypass: bypass} do + Bypass.expect(bypass, fn conn -> + case conn.request_path do + "/api/v1/1/addresses:batch_resolve_names" -> + Conn.resp(conn, 200, Jason.encode!(%{"names" => %{checksummed_address_hash() => @ens_name}})) + + _metadata_path -> + Conn.resp(conn, 500, "") + end + end) + + [address] = MetadataPreloader.maybe_preload_ens_and_metadata([address()]) + + assert address.ens_domain_name == @ens_name + assert address.metadata == nil + end + + test "returns a single entity as-is, not wrapped in a list", %{bypass: bypass} do + expect_both_microservices(bypass) + + address = MetadataPreloader.maybe_preload_ens_and_metadata(address()) + + assert %Address{ens_domain_name: @ens_name} = address + end + + test "skips both microservices when there are no address hashes" do + # any request to the bypass would fail the test, since nothing is expected + assert MetadataPreloader.maybe_preload_ens_and_metadata([]) == [] + end + end + + describe "maybe_preload_selected_meta/2" do + test "queries both microservices concurrently when both fields are requested", %{bypass: bypass} do + expect_both_microservices(bypass) + + address = MetadataPreloader.maybe_preload_selected_meta(address(), [:ens_domain_name, :metadata]) + + assert address.ens_domain_name == @ens_name + assert address.metadata == %{"tags" => [@metadata_tag]} + end + + test "queries only BENS when just the ENS field is requested", %{bypass: bypass} do + expect_only(bypass, "/api/v1/1/addresses:batch_resolve_names") + + address = MetadataPreloader.maybe_preload_selected_meta(address(), [:ens_domain_name]) + + assert address.ens_domain_name == @ens_name + assert address.metadata == nil + end + + test "queries only Metadata when just the metadata field is requested", %{bypass: bypass} do + expect_only(bypass, "/api/v1/metadata") + + address = MetadataPreloader.maybe_preload_selected_meta(address(), [:metadata]) + + assert address.ens_domain_name == nil + assert address.metadata == %{"tags" => [@metadata_tag]} + end + + test "queries nothing when no field is requested" do + # any request to the bypass would fail the test, since nothing is expected + assert MetadataPreloader.maybe_preload_selected_meta(address(), []) == address() + end + + test "ignores DISABLE_TRANSACTIONS_BENS_PRELOAD, since the caller opted in explicitly", %{bypass: bypass} do + Application.put_env( + :explorer, + BENS, + Keyword.put(Application.get_env(:explorer, BENS), :disable_transactions_bens_preload, true) + ) + + expect_only(bypass, "/api/v1/1/addresses:batch_resolve_names") + + address = MetadataPreloader.maybe_preload_selected_meta(address(), [:ens_domain_name]) + + assert address.ens_domain_name == @ens_name + end + end + + defp address do + {:ok, hash} = Chain.string_to_address_hash(@address_hash_string) + %Address{hash: hash} + end + + defp checksummed_address_hash, do: Address.checksum(@address_hash_string) + + # fails the test if any path other than `expected_path` is requested + defp expect_only(bypass, expected_path) do + Bypass.expect(bypass, fn %{request_path: ^expected_path} = conn -> + Conn.resp(conn, 200, response_body(expected_path)) + end) + end + + defp response_body("/api/v1/1/addresses:batch_resolve_names") do + Jason.encode!(%{"names" => %{checksummed_address_hash() => @ens_name}}) + end + + defp response_body("/api/v1/metadata") do + Jason.encode!(%{ + "addresses" => %{ + checksummed_address_hash() => %{"tags" => [Map.put(@metadata_tag, "meta", Jason.encode!(%{}))]} + } + }) + end + + defp expect_both_microservices(bypass) do + Bypass.expect(bypass, fn conn -> + Conn.resp(conn, 200, response_body(conn.request_path)) + end) + end +end diff --git a/apps/explorer/test/explorer/microservice_interfaces/http_client_test.exs b/apps/explorer/test/explorer/microservice_interfaces/http_client_test.exs new file mode 100644 index 00000000000..44efb679b4f --- /dev/null +++ b/apps/explorer/test/explorer/microservice_interfaces/http_client_test.exs @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.MicroserviceInterfaces.HttpClientTest do + use ExUnit.Case, async: false + + alias Explorer.MicroserviceInterfaces.HttpClient + alias Plug.Conn + + @reuse_event [:finch, :reused_connection] + + setup do + bypass = Bypass.open() + test_process = self() + + # a stub rather than an expectation: not every test here makes a request, + # and the ones that do assert on :request_received themselves + Bypass.stub(bypass, "GET", "/api/v1/metadata", fn conn -> + send(test_process, :request_received) + Conn.resp(conn, 200, "{}") + end) + + handler_id = {__MODULE__, self()} + + :telemetry.attach( + handler_id, + @reuse_event, + fn @reuse_event, _measurements, metadata, _config -> send(test_process, {:connection_reused, metadata.name}) end, + nil + ) + + on_exit(fn -> + :telemetry.detach(handler_id) + Bypass.down(bypass) + end) + + {:ok, url: "http://localhost:#{bypass.port}/api/v1/metadata"} + end + + describe "get/3" do + test "reuses the pooled connection instead of opening one per request", %{url: url} do + assert {:ok, %{status_code: 200}} = HttpClient.get(url) + assert_received :request_received + + assert {:ok, %{status_code: 200}} = HttpClient.get(url) + assert_received :request_received + + # the second request was served over the connection the first one opened, + # which is what makes it skip DNS and the TCP + TLS handshake + assert_received {:connection_reused, HttpClient.Finch} + end + end + + describe "proxy_get/3" do + test "uses the Finch instance reserved for long-running requests", %{url: url} do + assert {:ok, %{status_code: 200}} = HttpClient.proxy_get(url) + assert {:ok, %{status_code: 200}} = HttpClient.proxy_get(url) + assert_received {:connection_reused, HttpClient.ProxyFinch} + + # nothing went through the pool that serves the latency-critical preloads + refute_received {:connection_reused, HttpClient.Finch} + end + end + + describe "pool_child_specs/0" do + test "starts both Finch instances, sized from the MICROSERVICE_HTTP_POOL_SIZE configuration", %{url: url} do + max_connections = Application.get_env(:explorer, :microservice_http_pool_size) + + # pools are started lazily per origin, so make one request through each + assert {:ok, %{status_code: 200}} = HttpClient.get(url) + assert {:ok, %{status_code: 200}} = HttpClient.proxy_get(url) + + for finch_name <- [HttpClient.Finch, HttpClient.ProxyFinch] do + assert {:ok, [%Finch.HTTP1.PoolMetrics{pool_size: ^max_connections}]} = + Finch.get_pool_status(finch_name, url) + end + end + end +end diff --git a/config/runtime.exs b/config/runtime.exs index 674ca2df0a1..d7ed318c04e 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -348,7 +348,8 @@ config :explorer, csv_export_limit: ConfigHelper.parse_integer_env_var("CSV_EXPORT_LIMIT", 10_000), shrink_internal_transactions_enabled: ConfigHelper.parse_bool_env_var("SHRINK_INTERNAL_TRANSACTIONS_ENABLED"), replica_max_lag: ConfigHelper.parse_time_env_var("REPLICA_MAX_LAG", "5m"), - hackney_default_pool_size: ConfigHelper.parse_integer_env_var("HACKNEY_DEFAULT_POOL_SIZE", 1_000) + hackney_default_pool_size: ConfigHelper.parse_integer_env_var("HACKNEY_DEFAULT_POOL_SIZE", 1_000), + microservice_http_pool_size: ConfigHelper.parse_integer_env_var("MICROSERVICE_HTTP_POOL_SIZE", 1_000) config :explorer, Explorer.Chain.Health.Monitor, check_interval: ConfigHelper.parse_time_env_var("HEALTH_MONITOR_CHECK_INTERVAL", "1m"), diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index b3c177aae5c..3b72664d01c 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -718,4 +718,5 @@ RELEASE_COOKIE=secret_cookie # RE_CAPTCHA_CLIENT_KEY= # RE_CAPTCHA_V3_CLIENT_KEY= # HACKNEY_DEFAULT_POOL_SIZE=1000 +# MICROSERVICE_HTTP_POOL_SIZE=1000 # UNIVERSAL_PROXY_CONFIG_URL= diff --git a/mix.lock b/mix.lock index 4db9833011e..e9fe371b391 100644 --- a/mix.lock +++ b/mix.lock @@ -77,7 +77,7 @@ "ezstd": {:hex, :ezstd, "1.2.4", "7ab3ed4bf5ed93e249c936f457a060cf99487f392a41b2b3fd0f93e2c792a7d6", [:rebar3], [], "hexpm", "c79a63c8f1706ca5402d4d97347a1934f41fd8dd055c7af4ca92b9bcbef6d1c0"}, "file_info": {:hex, :file_info, "0.0.4", "2e0e77f211e833f38ead22cb29ce53761d457d80b3ffe0ffe0eb93880b0963b2", [:mix], [{:mimetype_parser, "~> 0.1.2", [hex: :mimetype_parser, repo: "hexpm", optional: false]}], "hexpm", "50e7ad01c2c8b9339010675fe4dc4a113b8d6ca7eddce24d1d74fd0e762781a5"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, + "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, "floki": {:hex, :floki, "0.38.4", "10f98971e892aed2c2f1b3a0f928e488e3797e1c6dd3dfd98db40b14e9a78bcf", [:mix], [], "hexpm", "bdb34645eee8e79845c7edaca2d4099a52804ee4d4a3ecc683a69451f0244973"}, "flow": {:hex, :flow, "1.2.4", "1dd58918287eb286656008777cb32714b5123d3855956f29aa141ebae456922d", [:mix], [{:gen_stage, "~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}], "hexpm", "874adde96368e71870f3510b91e35bc31652291858c86c0e75359cbdd35eb211"}, "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, From b27272905ca224d2ad318df55965a64713b7b621 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Thu, 13 Aug 2026 13:17:09 +0300 Subject: [PATCH 07/18] perf: Fetch address existence checks in a single query (#14694) --- .../views/api/v2/address_view.ex | 12 ++++--- .../lib/explorer/chain/address/counters.ex | 35 ++++++++++++++++++- .../explorer/lib/explorer/chain/withdrawal.ex | 10 ++++++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/address_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/address_view.ex index 8f62977ef20..3ae83b75cf3 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/address_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/address_view.ex @@ -109,6 +109,8 @@ defmodule BlockScoutWeb.API.V2.AddressView do creation_transaction_hash = creator_hash && AddressView.transaction_hash(address) token = address.token && TokenView.render("token.json", %{token: address.token}) + existence_checks = Counters.address_existence_checks(address.hash, @api_true) + extended_info = Map.merge(base_info, %{ "creator_address_hash" => creator_hash && Address.checksum(creator_hash), @@ -118,12 +120,12 @@ defmodule BlockScoutWeb.API.V2.AddressView do "coin_balance" => balance, "exchange_rate" => exchange_rate, "block_number_balance_updated_at" => address.fetched_coin_balance_block_number, - "has_validated_blocks" => Counters.check_if_validated_blocks_at_address(address.hash, @api_true), - "has_logs" => Counters.check_if_logs_at_address(address.hash, @api_true), - "has_tokens" => Counters.check_if_tokens_at_address(address.hash, @api_true), - "has_token_transfers" => Counters.check_if_token_transfers_at_address(address.hash, @api_true), + "has_validated_blocks" => existence_checks.has_validated_blocks, + "has_logs" => existence_checks.has_logs, + "has_tokens" => existence_checks.has_tokens, + "has_token_transfers" => existence_checks.has_token_transfers, "watchlist_address_id" => WatchlistAddress.select_watchlist_address_id(get_watchlist_id(conn), address.hash), - "has_beacon_chain_withdrawals" => Counters.check_if_withdrawals_at_address(address.hash, @api_true) + "has_beacon_chain_withdrawals" => existence_checks.has_beacon_chain_withdrawals }) extended_info diff --git a/apps/explorer/lib/explorer/chain/address/counters.ex b/apps/explorer/lib/explorer/chain/address/counters.ex index 9d663193a48..4624da788cf 100644 --- a/apps/explorer/lib/explorer/chain/address/counters.ex +++ b/apps/explorer/lib/explorer/chain/address/counters.ex @@ -80,10 +80,43 @@ defmodule Explorer.Chain.Address.Counters do @spec check_if_withdrawals_at_address(Hash.Address.t()) :: boolean() def check_if_withdrawals_at_address(address_hash, options \\ []) do address_hash - |> Withdrawal.address_hash_to_withdrawals_unordered_query() + |> Withdrawal.address_hash_to_withdrawals_existence_query() |> select_repo(options).exists?() end + @doc """ + Performs all existence checks needed by the address view in a single + database round trip: `SELECT exists(...), exists(...), ...`. + """ + @spec address_existence_checks(Hash.Address.t(), Keyword.t()) :: %{ + has_validated_blocks: boolean(), + has_logs: boolean(), + has_tokens: boolean(), + has_token_transfers: boolean(), + has_beacon_chain_withdrawals: boolean() + } + def address_existence_checks(address_hash, options \\ []) do + validated_blocks_query = address_hash |> address_hash_to_validated_blocks_query() |> select([_], 1) + logs_query = address_hash |> address_hash_to_logs_query() |> select([_], 1) + token_balances_query = address_hash |> address_hash_to_token_balances_query() |> select([_], 1) + token_transfers_from_query = from(tt in TokenTransfer, where: tt.from_address_hash == ^address_hash, select: 1) + token_transfers_to_query = from(tt in TokenTransfer, where: tt.to_address_hash == ^address_hash, select: 1) + withdrawals_query = Withdrawal.address_hash_to_withdrawals_existence_query(address_hash) + + query = + from(f in fragment("SELECT 1"), + select: %{ + has_validated_blocks: exists(validated_blocks_query), + has_logs: exists(logs_query), + has_tokens: exists(token_balances_query), + has_token_transfers: exists(token_transfers_from_query) or exists(token_transfers_to_query), + has_beacon_chain_withdrawals: exists(withdrawals_query) + } + ) + + select_repo(options).one(query) + end + def address_hash_to_transaction_count_query(address_hash) do dynamic = Transaction.where_transactions_to_from(address_hash) diff --git a/apps/explorer/lib/explorer/chain/withdrawal.ex b/apps/explorer/lib/explorer/chain/withdrawal.ex index 02af2fcceb7..3ad99d805f9 100644 --- a/apps/explorer/lib/explorer/chain/withdrawal.ex +++ b/apps/explorer/lib/explorer/chain/withdrawal.ex @@ -86,6 +86,16 @@ defmodule Explorer.Chain.Withdrawal do ) end + @spec address_hash_to_withdrawals_existence_query(Hash.Address.t()) :: Ecto.Query.t() + def address_hash_to_withdrawals_existence_query(address_hash) do + from(withdrawal in __MODULE__, + left_join: block in assoc(withdrawal, :block), + where: withdrawal.address_hash == ^address_hash, + where: block.consensus == true, + select: 1 + ) + end + @spec blocks_without_withdrawals_query(non_neg_integer()) :: Ecto.Query.t() def blocks_without_withdrawals_query(from_block) do from(withdrawal in __MODULE__, From 6292a1759c2883ea4c82d5f39b3b28aeca1071d1 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Thu, 13 Aug 2026 14:19:57 +0300 Subject: [PATCH 08/18] perf: cache empty implementations of verified contracts longer (#14696) --- apps/explorer/config/test.exs | 1 + .../proxy/models/implementation.ex | 79 +++++++++++++------ .../proxy/models/implementation_test.exs | 41 ++++++++++ .../chain/smart_contract/proxy_test.exs | 4 + config/runtime.exs | 2 + docker-compose/envs/common-blockscout.env | 1 + 6 files changed, 104 insertions(+), 24 deletions(-) diff --git a/apps/explorer/config/test.exs b/apps/explorer/config/test.exs index 965d593c62c..71632db104a 100644 --- a/apps/explorer/config/test.exs +++ b/apps/explorer/config/test.exs @@ -49,6 +49,7 @@ config :explorer, :proxy, caching_implementation_data_enabled: true, implementation_data_ttl_via_avg_block_time: false, fallback_cached_implementation_data_ttl: :timer.seconds(20), + empty_cached_implementation_data_ttl: :timer.seconds(20), implementation_data_fetching_timeout: :timer.seconds(20) account_database_url = System.get_env("TEST_DATABASE_READ_ONLY_API_URL") || database_url 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 9700f14ea6a..56287dcbdc9 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 @@ -168,10 +168,11 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do } = smart_contract, options ) do - implementation_updated_at = get_proxy_implementation_updated_at(address_hash, options) + proxy_implementations = get_proxy_implementations(address_hash, options) + implementation_updated_at = proxy_implementations && proxy_implementations.updated_at {updated_smart_contract, implementation_address_fetched?} = - if check_implementation_refetch_necessity(implementation_updated_at) do + if implementation_refetch_necessary?(proxy_implementations, smart_contract) do {smart_contract_with_bytecode_twin, implementation_address_fetched?} = SmartContract.address_hash_to_smart_contract_with_bytecode_twin(address_hash, options) @@ -202,10 +203,8 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity def get_implementation( %{ - updated: %SmartContract{ - address_hash: address_hash - }, - implementation_updated_at: implementation_updated_at, + updated: %SmartContract{address_hash: address_hash} = smart_contract, + implementation_updated_at: _implementation_updated_at, implementation_address_fetched?: implementation_address_fetched?, refetch_necessity_checked?: refetch_necessity_checked? }, @@ -213,9 +212,12 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do ) do proxy_implementations = get_proxy_implementations(address_hash, options) - implementation_updated_at = implementation_updated_at || (proxy_implementations && proxy_implementations.updated_at) - - if fetch_implementation?(implementation_address_fetched?, refetch_necessity_checked?, implementation_updated_at) do + if fetch_implementation?( + implementation_address_fetched?, + refetch_necessity_checked?, + proxy_implementations, + smart_contract + ) do get_implementation_address_hash_task = Task.async(fn -> # Here and only here we fetch implementations for the given address @@ -254,11 +256,43 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do def get_implementation(_, _), do: nil - defp fetch_implementation?(implementation_address_fetched?, refetch_necessity_checked?, implementation_updated_at) do + defp fetch_implementation?( + implementation_address_fetched?, + refetch_necessity_checked?, + proxy_implementations, + smart_contract + ) do (!implementation_address_fetched? || !refetch_necessity_checked?) && - check_implementation_refetch_necessity(implementation_updated_at) + implementation_refetch_necessary?(proxy_implementations, smart_contract) + end + + # Decides whether the cached implementations row needs to be refetched. Empty + # cached results ("not a proxy") for smart-contracts verified on the address + # itself are kept for a much longer TTL than positive ones: a verified + # non-proxy contract is unlikely to become a proxy, so re-probing all known + # proxy patterns via JSON-RPC on every regular cache expiry is wasted work on + # the hot path of API requests. + @spec implementation_refetch_necessary?(__MODULE__.t() | nil, SmartContract.t() | nil) :: boolean() + defp implementation_refetch_necessary?(nil, _smart_contract), do: true + + defp implementation_refetch_necessary?(%__MODULE__{} = proxy_implementations, smart_contract) do + if Enum.empty?(proxy_implementations.address_hashes) && verified_on_this_address?(smart_contract) do + ttl = Application.get_env(:explorer, :proxy)[:empty_cached_implementation_data_ttl] || get_fresh_time_distance() + + implementation_data_stale?(proxy_implementations.updated_at, ttl) + else + check_implementation_refetch_necessity(proxy_implementations.updated_at) + end end + # Smart-contract verified on the address itself: loaded from the DB and not a + # bytecode twin substitute built for an unverified address. + defp verified_on_this_address?(%SmartContract{__meta__: %Ecto.Schema.Metadata{state: :loaded}} = smart_contract) do + !smart_contract.metadata_from_verified_bytecode_twin + end + + defp verified_on_this_address?(_), do: false + @doc """ Function checks by timestamp if new implementation fetching needed """ @@ -266,13 +300,15 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do def check_implementation_refetch_necessity(nil), do: true def check_implementation_refetch_necessity(timestamp) do + implementation_data_stale?(timestamp, get_fresh_time_distance()) + end + + defp implementation_data_stale?(timestamp, ttl) do if Application.get_env(:explorer, :proxy)[:caching_implementation_data_enabled] do now = DateTime.utc_now() - fresh_time_distance = get_fresh_time_distance() - timestamp - |> DateTime.add(fresh_time_distance, :millisecond) + |> DateTime.add(ttl, :millisecond) |> DateTime.compare(now) != :gt else true @@ -365,6 +401,10 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do |> Repo.insert(on_conflict: on_conflict(), conflict_target: [:proxy_address_hash], allow_stale: true) end + # The update deliberately runs even when the data is unchanged: a successful + # re-probe must bump `updated_at`, otherwise cached results (including empty + # "not a proxy" ones) are considered stale forever after the first TTL window + # and every subsequent request re-probes the proxy patterns via JSON-RPC. defp on_conflict do from( proxy_implementations in __MODULE__, @@ -378,16 +418,7 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation do inserted_at: fragment("LEAST(?, EXCLUDED.inserted_at)", proxy_implementations.inserted_at), updated_at: fragment("GREATEST(?, EXCLUDED.updated_at)", proxy_implementations.updated_at) ] - ], - where: - fragment("EXCLUDED.proxy_type <> ?", proxy_implementations.proxy_type) or - fragment("EXCLUDED.address_hashes <> ?", proxy_implementations.address_hashes) or - fragment("EXCLUDED.names <> ?", proxy_implementations.names) or - fragment("EXCLUDED.conflicting_proxy_types IS DISTINCT FROM ?", proxy_implementations.conflicting_proxy_types) or - fragment( - "EXCLUDED.conflicting_address_hashes IS DISTINCT FROM ?", - proxy_implementations.conflicting_address_hashes - ) + ] ) end diff --git a/apps/explorer/test/explorer/chain/smart_contract/proxy/models/implementation_test.exs b/apps/explorer/test/explorer/chain/smart_contract/proxy/models/implementation_test.exs index 804d8670aee..66a26f04ea5 100644 --- a/apps/explorer/test/explorer/chain/smart_contract/proxy/models/implementation_test.exs +++ b/apps/explorer/test/explorer/chain/smart_contract/proxy/models/implementation_test.exs @@ -13,7 +13,47 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation.Test do setup :set_mox_global describe "fetching implementation" do + test "get_implementation/1 does not re-probe verified non-proxy within empty cache TTL" do + initial_proxy_config = Application.get_env(:explorer, :proxy) + on_exit(fn -> Application.put_env(:explorer, :proxy, initial_proxy_config) end) + + smart_contract = insert(:smart_contract) + + proxy = + :explorer + |> Application.get_env(:proxy) + |> Keyword.replace(:fallback_cached_implementation_data_ttl, :timer.seconds(20)) + |> Keyword.replace(:implementation_data_fetching_timeout, :timer.seconds(20)) + |> Keyword.put(:empty_cached_implementation_data_ttl, :timer.hours(24)) + + Application.put_env(:explorer, :proxy, proxy) + + EthereumJSONRPC.Mox + |> TestHelper.mock_generic_proxy_requests() + + assert %Implementation{address_hashes: [], names: [], proxy_type: nil} = + Implementation.get_implementation(smart_contract) + + verify!(EthereumJSONRPC.Mox) + + # expire the regular TTL: the cached empty result of a verified non-proxy + # must still be served without re-probing via JSON RPC (no Mox + # expectations are set, so any request would raise) + proxy = + :explorer + |> Application.get_env(:proxy) + |> Keyword.replace(:fallback_cached_implementation_data_ttl, 0) + + Application.put_env(:explorer, :proxy, proxy) + + assert %Implementation{address_hashes: [], names: [], proxy_type: nil} = + Implementation.get_implementation(smart_contract) + end + test "get_implementation/1" do + initial_proxy_config = Application.get_env(:explorer, :proxy) + on_exit(fn -> Application.put_env(:explorer, :proxy, initial_proxy_config) end) + smart_contract = insert(:smart_contract) implementation_smart_contract = insert(:smart_contract, name: "implementation") @@ -42,6 +82,7 @@ defmodule Explorer.Chain.SmartContract.Proxy.Models.Implementation.Test do :explorer |> Application.get_env(:proxy) |> Keyword.replace(:fallback_cached_implementation_data_ttl, 0) + |> Keyword.put(:empty_cached_implementation_data_ttl, 0) Application.put_env(:explorer, :proxy, proxy) diff --git a/apps/explorer/test/explorer/chain/smart_contract/proxy_test.exs b/apps/explorer/test/explorer/chain/smart_contract/proxy_test.exs index bad2b162233..dfa62c3b68b 100644 --- a/apps/explorer/test/explorer/chain/smart_contract/proxy_test.exs +++ b/apps/explorer/test/explorer/chain/smart_contract/proxy_test.exs @@ -350,6 +350,9 @@ defmodule Explorer.Chain.SmartContract.ProxyTest do end test "check proxy_contract?/1 function" do + initial_proxy_config = Application.get_env(:explorer, :proxy) + on_exit(fn -> Application.put_env(:explorer, :proxy, initial_proxy_config) end) + smart_contract = insert(:smart_contract) proxy = @@ -374,6 +377,7 @@ defmodule Explorer.Chain.SmartContract.ProxyTest do :explorer |> Application.get_env(:proxy) |> Keyword.replace(:fallback_cached_implementation_data_ttl, 0) + |> Keyword.put(:empty_cached_implementation_data_ttl, 0) Application.put_env(:explorer, :proxy, proxy) diff --git a/config/runtime.exs b/config/runtime.exs index d7ed318c04e..3f5ceb1ce47 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -363,6 +363,8 @@ config :explorer, :proxy, implementation_data_ttl_via_avg_block_time: ConfigHelper.parse_bool_env_var("CONTRACT_PROXY_IMPLEMENTATION_TTL_VIA_AVG_BLOCK_TIME", "true"), fallback_cached_implementation_data_ttl: :timer.seconds(4), + empty_cached_implementation_data_ttl: + ConfigHelper.parse_time_env_var("CONTRACT_PROXY_EMPTY_IMPLEMENTATION_DATA_CACHE_TTL", "1d"), implementation_data_fetching_timeout: :timer.seconds(2) config :explorer, Explorer.Chain.Events.Listener, diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index 3b72664d01c..4d469d88714 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -152,6 +152,7 @@ RELEASE_LINK= # CONTRACT_AUDIT_REPORTS_AIRTABLE_API_KEY= # CONTRACT_CERTIFIED_LIST= # CONTRACT_ENABLE_PARTIAL_REVERIFICATION= +# CONTRACT_PROXY_EMPTY_IMPLEMENTATION_DATA_CACHE_TTL=1d # UNCLES_IN_AVERAGE_BLOCK_TIME=false # DISABLE_BLOCK_BROADCAST_ENRICHMENT=false # BLOCK_BROADCAST_TYPE=block From 4d5053592de68d5ddaa22b3aa830573b4f50247d Mon Sep 17 00:00:00 2001 From: nikitosing <32202610+nikitosing@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:09:50 +0300 Subject: [PATCH 09/18] fix: Add missed metadata field to /preview (#14703) --- .../views/api/v2/transaction_view.ex | 6 +- .../api/v2/transaction_controller_test.exs | 60 ++++++++++++++++++- 2 files changed, 61 insertions(+), 5 deletions(-) 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 a9606ee6b57..11c6cb428e0 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 @@ -920,7 +920,8 @@ defmodule BlockScoutWeb.API.V2.TransactionView do %{ "hash" => Address.checksum(address), "name" => Helper.address_name(address), - "ens_domain_name" => address.ens_domain_name + "ens_domain_name" => address.ens_domain_name, + "metadata" => address.metadata } end @@ -930,7 +931,8 @@ defmodule BlockScoutWeb.API.V2.TransactionView do %{ "hash" => Address.checksum(hash), "name" => nil, - "ens_domain_name" => nil + "ens_domain_name" => nil, + "metadata" => nil } end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs index f6fd273280d..cc6c990c4d0 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs @@ -3503,18 +3503,33 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do setup do bypass = Bypass.open() old_bens_env = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.BENS, []) + old_metadata_env = Application.get_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, []) old_chain_id = Application.get_env(:block_scout_web, :chain_id) Application.put_env(:block_scout_web, :chain_id, 1) - Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, - service_url: "http://localhost:#{bypass.port}", - enabled: true + Application.put_env( + :explorer, + Explorer.MicroserviceInterfaces.BENS, + Keyword.merge(old_bens_env || [], + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) + ) + + Application.put_env( + :explorer, + Explorer.MicroserviceInterfaces.Metadata, + Keyword.merge(old_metadata_env || [], + service_url: "http://localhost:#{bypass.port}", + enabled: true + ) ) on_exit(fn -> Bypass.down(bypass) Application.put_env(:explorer, Explorer.MicroserviceInterfaces.BENS, old_bens_env) + Application.put_env(:explorer, Explorer.MicroserviceInterfaces.Metadata, old_metadata_env) Application.put_env(:block_scout_web, :chain_id, old_chain_id) end) @@ -3535,6 +3550,45 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do assert %{"from" => %{"ens_domain_name" => "preview.eth"}} = json_response(request, 200) end + test "preloads metadata when preload_metadata is requested", %{conn: conn, bypass: bypass} do + transaction = :transaction |> insert() |> with_block(status: :ok) + from_hash = Address.checksum(transaction.from_address_hash) + to_hash = Address.checksum(transaction.to_address_hash) + + metadata_tag = %{"name" => "Test 1", "tagType" => "name", "meta" => Jason.encode!(%{})} + + Bypass.expect_once(bypass, "GET", "/api/v1/metadata", fn conn -> + Plug.Conn.resp( + conn, + 200, + Jason.encode!(%{ + "addresses" => %{ + from_hash => %{"tags" => [metadata_tag]}, + to_hash => %{"tags" => [metadata_tag]} + } + }) + ) + end) + + request = + get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/preview", %{"preload_metadata" => "true"}) + + response = json_response(request, 200) + + assert %{"from" => %{"metadata" => %{"tags" => [%{"name" => "Test 1"}]}}} = response + assert %{"to" => %{"metadata" => %{"tags" => [%{"name" => "Test 1"}]}}} = response + end + + test "returns nil metadata when preload_metadata is not requested", %{conn: conn} do + transaction = :transaction |> insert() |> with_block(status: :ok) + + request = get(conn, "/api/v2/transactions/#{to_string(transaction.hash)}/preview") + + response = json_response(request, 200) + assert %{"from" => %{"metadata" => nil}} = response + assert %{"to" => %{"metadata" => nil}} = response + end + test "queries no microservice when neither preload is requested", %{conn: conn} do transaction = :transaction |> insert() |> with_block(status: :ok) From 40349b01f1d50e73a6d2bb7517f98351b4ff0e0a Mon Sep 17 00:00:00 2001 From: nikitosing <32202610+nikitosing@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:45:33 +0300 Subject: [PATCH 10/18] chore: Update swagger spec for /preview (#14704) --- .../api/v2/transaction_controller.ex | 2 +- .../schemas/api/v2/transaction/preview.ex | 71 +++++++++++++++++++ .../api/v2/transaction_controller_test.exs | 8 ++- 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/preview.ex 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 a79a0007a50..bcc0ca905b3 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 @@ -1074,7 +1074,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do } ], responses: [ - ok: {"Lightweight transaction preview.", "application/json", %Schema{type: :object}}, + ok: {"Lightweight transaction preview.", "application/json", Schemas.Transaction.Preview}, not_found: NotFoundResponse.response(), unprocessable_entity: JsonErrorResponse.response() ] diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/preview.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/preview.ex new file mode 100644 index 00000000000..505fe3886f0 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/preview.ex @@ -0,0 +1,71 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Transaction.PreviewAddress do + @moduledoc """ + Lightweight address representation used in the transaction preview endpoint. + Contains only the hash, display name, ENS domain name, and metadata tags. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.{General, Proxy} + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Lightweight address in transaction preview", + type: :object, + nullable: true, + properties: %{ + hash: General.AddressHash, + name: %Schema{ + type: :string, + description: "Display name of the address (contract name or address name)", + nullable: true + }, + ens_domain_name: %Schema{ + type: :string, + description: "ENS domain name associated with the address. Populated only when preload_ens=true is passed.", + nullable: true + }, + metadata: %Schema{ + allOf: [Proxy.Metadata], + description: + "Address metadata tags from the Metadata microservice. Populated only when preload_metadata=true is passed.", + nullable: true + } + }, + required: [:hash, :name, :ens_domain_name, :metadata], + additionalProperties: false + }) +end + +defmodule BlockScoutWeb.Schemas.API.V2.Transaction.Preview do + @moduledoc """ + Schema for the lightweight transaction preview response returned by + `GET /api/v2/transactions/:transaction_hash/preview`. + + Used for rendering OG/social-media previews with minimal data. + """ + require OpenApiSpex + + alias BlockScoutWeb.Schemas.API.V2.General + alias BlockScoutWeb.Schemas.API.V2.Transaction.PreviewAddress + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + description: "Lightweight transaction preview for social media embeds", + type: :object, + properties: %{ + status: %Schema{ + type: :string, + enum: ["ok", "error"], + nullable: true, + description: "Transaction execution status" + }, + timestamp: General.TimestampNullable, + method: General.MethodNameNullable, + from: %Schema{allOf: [PreviewAddress], nullable: true}, + to: %Schema{allOf: [PreviewAddress], nullable: true} + }, + required: [:status, :timestamp, :method, :from, :to], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs index cc6c990c4d0..1f021d8bce4 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs @@ -3555,7 +3555,13 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do from_hash = Address.checksum(transaction.from_address_hash) to_hash = Address.checksum(transaction.to_address_hash) - metadata_tag = %{"name" => "Test 1", "tagType" => "name", "meta" => Jason.encode!(%{})} + metadata_tag = %{ + "slug" => "test-1", + "name" => "Test 1", + "tagType" => "name", + "ordinal" => 0, + "meta" => Jason.encode!(%{}) + } Bypass.expect_once(bypass, "GET", "/api/v1/metadata", fn conn -> Plug.Conn.resp( From a61dffaf5270353c64f29b62a8d2e753bc9bc8a8 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Mon, 17 Aug 2026 12:30:42 +0300 Subject: [PATCH 11/18] perf: reduce query count and payload in transaction API endpoint (#14705) --- .../api/v2/transaction_controller.ex | 61 ++++----- .../models/get_address_tags.ex | 127 +++++++++++++----- .../block_scout_web/views/api/v2/helper.ex | 14 ++ .../views/api/v2/transaction_view.ex | 31 ++++- apps/explorer/config/config.exs | 2 + apps/explorer/config/test.exs | 1 + apps/explorer/lib/explorer/application.ex | 5 + apps/explorer/lib/explorer/chain.ex | 62 +++++++++ .../explorer/chain/cache/contract_methods.ex | 81 +++++++++++ .../lib/explorer/chain/smart_contract.ex | 15 +++ .../lib/explorer/chain/transaction.ex | 28 +++- 11 files changed, 352 insertions(+), 75 deletions(-) create mode 100644 apps/explorer/lib/explorer/chain/cache/contract_methods.ex 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 bcc0ca905b3..81f89a4847a 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 @@ -58,7 +58,7 @@ defmodule BlockScoutWeb.API.V2.TransactionController do alias Explorer.Chain.Beacon.Deposit, as: BeaconDeposit alias Explorer.Chain.Beacon.Reader, as: BeaconReader alias Explorer.Chain.Cache.Counters.{NewPendingTransactionsCount, Transactions24hCount} - alias Explorer.Chain.{FheOperation, Hash, Transaction} + alias Explorer.Chain.{FheOperation, Hash, SmartContract, Transaction} alias Explorer.Chain.Optimism.TransactionBatch, as: OptimismTransactionBatch alias Explorer.Chain.Scroll.Reader, as: ScrollReader alias Explorer.Chain.Token.Instance @@ -88,45 +88,34 @@ defmodule BlockScoutWeb.API.V2.TransactionController do end # TODO might be redundant to preload blob fields in some of the endpoints - @transaction_necessity_by_association %{ - :block => :optional, - [ - created_contract_address: [ - :scam_badge, - :names, - :token, - :smart_contract, - proxy_implementations_association() - ] - ] => :optional, - [ - from_address: [ - :scam_badge, - :names, - :smart_contract, - proxy_implementations_association() - ] - ] => :optional, - [ - to_address: [ - :scam_badge, - :names, - :smart_contract, - proxy_implementations_association() - ] - ] => :optional - } + # Address-info preloads for the transaction participants (from/to/created and + # token transfer addresses) are intentionally absent here: the `transaction` + # action loads them all in a single deduplicated pass via + # `Chain.preload_transaction_participants/3` using + # `@transaction_participants_necessity_by_association`. + @transaction_necessity_by_association %{:block => :optional} |> Map.merge(@chain_type_transaction_necessity_by_association) + @transaction_participants_necessity_by_association %{ + :scam_badge => :optional, + :names => :optional, + :token => :optional, + SmartContract.association_without_abi() => :optional, + proxy_implementations_association() => :optional + } + @token_transfers_necessity_by_association %{ - [from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, - [to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, + [from_address: [:scam_badge, :names, SmartContract.association_without_abi(), proxy_implementations_association()]] => + :optional, + [to_address: [:scam_badge, :names, SmartContract.association_without_abi(), proxy_implementations_association()]] => + :optional, [token: reputation_association()] => :optional } + # Transfer from/to address preloads are handled by + # `Chain.preload_transaction_participants/3`, see + # `@transaction_participants_necessity_by_association`. @token_transfers_in_transaction_necessity_by_association %{ - [from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, - [to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] => :optional, [token: reputation_association()] => :optional } @@ -210,6 +199,12 @@ defmodule BlockScoutWeb.API.V2.TransactionController do transaction, @token_transfers_in_transaction_necessity_by_association, @api_true |> fetch_scam_token_toggle(conn) + ), + preloaded <- + Chain.preload_transaction_participants( + preloaded, + @transaction_participants_necessity_by_association, + @api_true ) do conn |> put_status(200) diff --git a/apps/block_scout_web/lib/block_scout_web/models/get_address_tags.ex b/apps/block_scout_web/lib/block_scout_web/models/get_address_tags.ex index e8dde140d33..d97fa17cd61 100644 --- a/apps/block_scout_web/lib/block_scout_web/models/get_address_tags.ex +++ b/apps/block_scout_web/lib/block_scout_web/models/get_address_tags.ex @@ -4,7 +4,7 @@ defmodule BlockScoutWeb.Models.GetAddressTags do Get various types of tags associated with the address """ - import Ecto.Query, only: [from: 2] + import Ecto.Query, only: [from: 2, select_merge: 3, where: 3] import Explorer.Chain, only: [select_repo: 1] @@ -27,6 +27,37 @@ defmodule BlockScoutWeb.Models.GetAddressTags do def get_address_tags(_, _, _), do: %{common_tags: [], personal_tags: [], watchlist_names: []} + @doc """ + Same as `get_address_tags/3` for multiple addresses at once, using a single + query per tag type instead of a query per address per tag type. + + Returns a map keyed by address hash with values of the same shape as + `get_address_tags/3` returns. + """ + def get_address_tags_batch(address_hashes, current_user, opts \\ []) + + def get_address_tags_batch([], _, _), do: %{} + + def get_address_tags_batch(address_hashes, current_user, opts) do + common_tags = address_hashes |> get_tags_on_addresses(opts) |> Enum.group_by(& &1.address_hash) + + personal_tags = address_hashes |> get_personal_tags_on_addresses(current_user) |> Enum.group_by(& &1.address_hash) + + watchlist_names = + address_hashes + |> get_watchlist_names_on_addresses(current_user) + |> Enum.group_by(& &1.address_hash, &Map.delete(&1, :address_hash)) + + Map.new(address_hashes, fn address_hash -> + {address_hash, + %{ + common_tags: Map.get(common_tags, address_hash, []), + personal_tags: Map.get(personal_tags, address_hash, []), + watchlist_names: Map.get(watchlist_names, address_hash, []) + }} + end) + end + def get_public_tags(address_hash, opts \\ []) when not is_nil(address_hash) do %{ common_tags: get_tags_on_address(address_hash, opts) @@ -36,46 +67,82 @@ defmodule BlockScoutWeb.Models.GetAddressTags do def get_tags_on_address(address_hash, opts \\ []) def get_tags_on_address(address_hash, opts) when not is_nil(address_hash) do - query = - from( - tt in AddressTag, - left_join: att in AddressToTag, - on: tt.id == att.tag_id, - where: att.address_hash == ^address_hash, - where: tt.label != ^"validator", - select: %{label: tt.label, display_name: tt.display_name, address_hash: att.address_hash} - ) - - select_repo(opts).all(query) + common_tags_base_query() + |> where([tt, att], att.address_hash == ^address_hash) + |> select_repo(opts).all() end def get_tags_on_address(_, _), do: [] + defp get_tags_on_addresses(address_hashes, opts) do + common_tags_base_query() + |> where([tt, att], att.address_hash in ^address_hashes) + |> select_repo(opts).all() + end + + defp common_tags_base_query do + from( + tt in AddressTag, + left_join: att in AddressToTag, + on: tt.id == att.tag_id, + where: tt.label != ^"validator", + select: %{label: tt.label, display_name: tt.display_name, address_hash: att.address_hash} + ) + end + def get_personal_tags(address_hash, %{id: id}) when not is_nil(address_hash) do - query = - from( - ta in TagAddress, - where: ta.address_hash_hash == ^address_hash, - where: ta.identity_id == ^id, - select: %{label: ta.name, display_name: ta.name, address_hash: ta.address_hash} - ) - - Repo.account_repo().all(query) + id + |> personal_tags_base_query() + |> where([ta], ta.address_hash_hash == ^address_hash) + |> Repo.account_repo().all() end def get_personal_tags(_, _), do: [] + defp get_personal_tags_on_addresses(address_hashes, %{id: id}) do + id + |> personal_tags_base_query() + |> where([ta], ta.address_hash_hash in ^address_hashes) + |> Repo.account_repo().all() + end + + defp get_personal_tags_on_addresses(_, _), do: [] + + defp personal_tags_base_query(identity_id) do + from( + ta in TagAddress, + where: ta.identity_id == ^identity_id, + select: %{label: ta.name, display_name: ta.name, address_hash: ta.address_hash} + ) + end + def get_watchlist_names_on_address(address_hash, %{watchlist_id: watchlist_id}) when not is_nil(address_hash) do - query = - from( - wa in WatchlistAddress, - where: wa.address_hash_hash == ^address_hash, - where: wa.watchlist_id == ^watchlist_id, - select: %{label: wa.name, display_name: wa.name} - ) - - Repo.account_repo().all(query) + watchlist_id + |> watchlist_names_base_query() + |> where([wa], wa.address_hash_hash == ^address_hash) + |> Repo.account_repo().all() end def get_watchlist_names_on_address(_, _), do: [] + + defp get_watchlist_names_on_addresses(address_hashes, %{watchlist_id: watchlist_id}) do + watchlist_id + |> watchlist_names_base_query() + |> where([wa], wa.address_hash_hash in ^address_hashes) + |> select_merge([wa], %{address_hash: wa.address_hash}) + |> Repo.account_repo().all() + end + + defp get_watchlist_names_on_addresses(_, _), do: [] + + # `address_hash` is not selected here on purpose: the per-address variant + # returns these maps directly in the API response, so the batch variant + # `select_merge`s it in only for grouping. + defp watchlist_names_base_query(watchlist_id) do + from( + wa in WatchlistAddress, + where: wa.watchlist_id == ^watchlist_id, + select: %{label: wa.name, display_name: wa.name} + ) + end end diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/helper.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/helper.ex index ea98179d65b..bfd51669cd3 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/helper.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/helper.ex @@ -33,6 +33,20 @@ defmodule BlockScoutWeb.API.V2.Helper do }) end + def address_with_info(_conn, address, address_hash, true, {:address_tags, address_tags}) do + %{ + common_tags: public_tags, + personal_tags: private_tags, + watchlist_names: watchlist_names + } = Map.get(address_tags, address_hash, %{common_tags: [], personal_tags: [], watchlist_names: []}) + + Map.merge(address_with_info(address, address_hash), %{ + "private_tags" => private_tags, + "watchlist_names" => watchlist_names, + "public_tags" => public_tags + }) + end + def address_with_info(_conn, address, address_hash, false, nil) do Map.merge(address_with_info(address, address_hash), %{ "private_tags" => [], 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 11c6cb428e0..14418b66410 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 @@ -9,7 +9,7 @@ defmodule BlockScoutWeb.API.V2.TransactionView do alias BlockScoutWeb.API.V2.{ApiView, Helper, InternalTransactionView, TokenTransferView, TokenView} - alias BlockScoutWeb.Models.GetTransactionTags + alias BlockScoutWeb.Models.{GetAddressTags, GetTransactionTags} alias BlockScoutWeb.{TransactionStateView, TransactionView} alias Ecto.Association.NotLoaded alias Explorer.{Chain, Market} @@ -27,6 +27,7 @@ defmodule BlockScoutWeb.API.V2.TransactionView do } alias Explorer.Chain.Block.Reward + alias Explorer.Chain.Cache.BlockNumber alias Explorer.Chain.Cache.Counters.AverageBlockTime alias Explorer.Chain.SmartContract.Proxy.Models.Implementation, as: ProxyImplementation alias Explorer.Chain.Transaction.StateChange @@ -78,7 +79,7 @@ defmodule BlockScoutWeb.API.V2.TransactionView do end def render("transaction.json", %{transaction: transaction, conn: conn}) do - block_height = Chain.block_height(@api_true) + block_height = BlockNumber.get_max() [decoded_input] = Transaction.decode_transactions([transaction], false, @api_true) transaction @@ -425,7 +426,7 @@ defmodule BlockScoutWeb.API.V2.TransactionView do end defp prepare_transactions(transactions, conn, watchlist_names) do - block_height = Chain.block_height(@api_true) + block_height = BlockNumber.get_max() decoded_transactions = Transaction.decode_transactions(transactions, true, @api_true) historic_exchange_rates = historic_exchange_rates(transactions) @@ -508,6 +509,13 @@ defmodule BlockScoutWeb.API.V2.TransactionView do block_timestamp = block_timestamp(transaction) + tags_data = + if single_transaction? do + batched_address_tags(transaction, conn) + else + watchlist_names + end + result = %{ "hash" => transaction.hash, "result" => status, @@ -520,7 +528,7 @@ defmodule BlockScoutWeb.API.V2.TransactionView do transaction.from_address, transaction.from_address_hash, single_transaction?, - watchlist_names + tags_data ), "to" => Helper.address_with_info( @@ -528,7 +536,7 @@ defmodule BlockScoutWeb.API.V2.TransactionView do transaction.to_address, transaction.to_address_hash, single_transaction?, - watchlist_names + tags_data ), "created_contract" => Helper.address_with_info( @@ -536,7 +544,7 @@ defmodule BlockScoutWeb.API.V2.TransactionView do transaction.created_contract_address, transaction.created_contract_address_hash, single_transaction?, - watchlist_names + tags_data ), "confirmations" => transaction.block |> Chain.confirmations(block_height: block_height) |> format_confirmations(), "confirmation_duration" => processing_time_duration(transaction), @@ -578,6 +586,17 @@ defmodule BlockScoutWeb.API.V2.TransactionView do transaction.block && transaction.block.base_fee_per_gas end + # Fetches tags for from/to/created addresses with one query per tag type + # instead of up to three queries per address. + defp batched_address_tags(transaction, conn) do + address_hashes = + [transaction.from_address_hash, transaction.to_address_hash, transaction.created_contract_address_hash] + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + + {:address_tags, GetAddressTags.get_address_tags_batch(address_hashes, current_user(conn), @api_true)} + end + defp gas_price_for_display(transaction) do transaction.gas_price || Transaction.effective_gas_price(transaction) end diff --git a/apps/explorer/config/config.exs b/apps/explorer/config/config.exs index 9bbc6129523..eedd6b54845 100644 --- a/apps/explorer/config/config.exs +++ b/apps/explorer/config/config.exs @@ -23,6 +23,8 @@ config :explorer, Explorer.ChainSpec.GenesisData, enabled: true config :explorer, Explorer.Chain.Cache.BlockNumber, enabled: true +config :explorer, Explorer.Chain.Cache.ContractMethods, enabled: true + config :explorer, Explorer.Chain.Cache.Counters.AddressesCoinBalanceSum, enabled: true, ttl_check_interval: :timer.seconds(1) diff --git a/apps/explorer/config/test.exs b/apps/explorer/config/test.exs index 71632db104a..1a6aaa300a2 100644 --- a/apps/explorer/config/test.exs +++ b/apps/explorer/config/test.exs @@ -103,6 +103,7 @@ end config :logger, :explorer, path: Path.absname("logs/test/explorer.log") +config :explorer, Explorer.Chain.Cache.ContractMethods, enabled: false config :explorer, Explorer.Chain.Fetcher.CheckBytecodeMatchingOnDemand, enabled: false config :explorer, Explorer.Chain.Fetcher.FetchValidatorInfoOnDemand, enabled: false config :explorer, Explorer.Tags.AddressTag.Cataloger, enabled: false diff --git a/apps/explorer/lib/explorer/application.ex b/apps/explorer/lib/explorer/application.ex index 57372668c08..c2ad1da0359 100644 --- a/apps/explorer/lib/explorer/application.ex +++ b/apps/explorer/lib/explorer/application.ex @@ -32,6 +32,7 @@ defmodule Explorer.Application do TransactionsCount } + alias Explorer.Chain.Cache.ContractMethods, as: ContractMethodsCache alias Explorer.Chain.Optimism.InteropMessage, as: OptimismInteropMessage alias Explorer.Chain.Supply.RSK @@ -105,6 +106,10 @@ defmodule Explorer.Application do global_ttl: :infinity ), con_cache_child_spec(RSK.cache_name(), ttl_check_interval: :timer.minutes(1), global_ttl: :timer.minutes(30)), + con_cache_child_spec(ContractMethodsCache.cache_name(), + ttl_check_interval: :timer.minutes(1), + global_ttl: :infinity + ), {Redix, redix_opts()}, {Explorer.Utility.ReplicaAccessibilityManager, []}, :hackney_pool.child_spec(:default, diff --git a/apps/explorer/lib/explorer/chain.ex b/apps/explorer/lib/explorer/chain.ex index 736df749574..a127a18c07f 100644 --- a/apps/explorer/lib/explorer/chain.ex +++ b/apps/explorer/lib/explorer/chain.ex @@ -1147,6 +1147,68 @@ defmodule Explorer.Chain do def get_token_transfers_per_transaction_preview_count, do: @token_transfers_per_transaction_preview + @doc """ + Loads address-info associations for every address participating in the + transaction — `from`/`to`/`created_contract` plus each preloaded token + transfer's `from`/`to` — in a single query pass, deduplicating addresses + shared between the transaction and its token transfers. + + All participants share `address_necessity_by_association` (typically with the + ABI-less smart-contract preload, see + `Explorer.Chain.SmartContract.association_without_abi/0`). The `to_address` + additionally gets the full `:smart_contract` association when it is a + contract, since transaction input and revert-reason decoding need its `abi`. + """ + @spec preload_transaction_participants(Transaction.t(), %{any() => :optional | :required}, [api?]) :: + Transaction.t() + def preload_transaction_participants(%Transaction{} = transaction, address_necessity_by_association, options) do + token_transfers = if is_list(transaction.token_transfers), do: transaction.token_transfers, else: [] + + participant_hashes = + [ + transaction.from_address_hash, + transaction.to_address_hash, + transaction.created_contract_address_hash + | Enum.flat_map(token_transfers, &[&1.from_address_hash, &1.to_address_hash]) + ] + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + + addresses = + Address + |> where([address], address.hash in ^participant_hashes) + |> join_associations(address_necessity_by_association) + |> select_repo(options).all() + |> Map.new(&{&1.hash, &1}) + + to_address = + addresses + |> Map.get(transaction.to_address_hash) + |> preload_full_smart_contract(options) + + %Transaction{ + transaction + | from_address: Map.get(addresses, transaction.from_address_hash), + to_address: to_address, + created_contract_address: Map.get(addresses, transaction.created_contract_address_hash), + token_transfers: + Enum.map(token_transfers, fn %TokenTransfer{} = token_transfer -> + %TokenTransfer{ + token_transfer + | from_address: Map.get(addresses, token_transfer.from_address_hash), + to_address: Map.get(addresses, token_transfer.to_address_hash) + } + end) + } + end + + defp preload_full_smart_contract(%Address{contract_code: contract_code} = address, options) + when not is_nil(contract_code) do + select_repo(options).preload(address, :smart_contract, force: true) + end + + defp preload_full_smart_contract(address, _options), do: address + @doc """ Converts list of `t:Explorer.Chain.Transaction.t/0` `hashes` to the list of `t:Explorer.Chain.Transaction.t/0`s for those `hashes`. diff --git a/apps/explorer/lib/explorer/chain/cache/contract_methods.ex b/apps/explorer/lib/explorer/chain/cache/contract_methods.ex new file mode 100644 index 00000000000..7b1132f1790 --- /dev/null +++ b/apps/explorer/lib/explorer/chain/cache/contract_methods.ex @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule Explorer.Chain.Cache.ContractMethods do + @moduledoc """ + Cache for `Explorer.Chain.ContractMethod` lookups by method id and for ABI + candidates returned by the sig-provider microservice. + + Candidates for a 4-byte selector change only when new methods are inserted on + contract verification, so found entries are cached with a long TTL and empty + results with a short one, keeping transaction input decoding off the DB and + HTTP paths for hot method ids. + """ + + alias Explorer.Chain.ContractMethod + + @cache_name :contract_methods + @found_ttl :timer.hours(1) + @not_found_ttl :timer.minutes(5) + + @spec cache_name() :: atom() + def cache_name, do: @cache_name + + @doc """ + Same as `Explorer.Chain.ContractMethod.find_contract_methods/2`, but serves + repeated lookups from cache. Ids missing from the cache are fetched from the + DB in a single query and cached, including ids with no known methods. + """ + @spec find_contract_methods([Explorer.Chain.MethodIdentifier.t()], keyword()) :: [ContractMethod.t()] + def find_contract_methods(method_ids, options) do + if enabled?() do + find_contract_methods_with_cache(method_ids, options) + else + ContractMethod.find_contract_methods(method_ids, options) + end + end + + @doc """ + Fetches the sig-provider ABI candidates for a method id, calling `fallback_fn` + and caching its result (including an empty one) on a cache miss. + """ + @spec fetch_sig_provider_abi(binary(), (-> [map()])) :: [map()] + def fetch_sig_provider_abi(method_id, fallback_fn) when is_function(fallback_fn, 0) do + if enabled?() do + {:ok, abi} = + ConCache.fetch_or_store(@cache_name, {:sig_provider_abi, method_id}, fn -> + {:ok, item(fallback_fn.())} + end) + + abi + else + fallback_fn.() + end + end + + defp find_contract_methods_with_cache(method_ids, options) do + {cached_methods, missing_ids} = + Enum.reduce(method_ids, {[], []}, fn method_id, {cached, missing} -> + case ConCache.get(@cache_name, {:contract_method, method_id}) do + nil -> {cached, [method_id | missing]} + methods -> {methods ++ cached, missing} + end + end) + + fetched_methods = ContractMethod.find_contract_methods(missing_ids, options) + fetched_by_id = Enum.group_by(fetched_methods, & &1.identifier) + + Enum.each(missing_ids, fn method_id -> + methods = Map.get(fetched_by_id, method_id, []) + ConCache.put(@cache_name, {:contract_method, method_id}, item(methods)) + end) + + cached_methods ++ fetched_methods + end + + defp enabled? do + Application.get_env(:explorer, __MODULE__)[:enabled] + end + + defp item(value) do + %ConCache.Item{value: value, ttl: if(value == [], do: @not_found_ttl, else: @found_ttl)} + end +end diff --git a/apps/explorer/lib/explorer/chain/smart_contract.ex b/apps/explorer/lib/explorer/chain/smart_contract.ex index 10d7f622ab9..590a414530e 100644 --- a/apps/explorer/lib/explorer/chain/smart_contract.ex +++ b/apps/explorer/lib/explorer/chain/smart_contract.ex @@ -260,6 +260,21 @@ defmodule Explorer.Chain.SmartContract do @dead_address_hash_string end + @doc """ + Returns a preload spec for the `:smart_contract` association that selects all + fields except `abi`. + + ABIs of popular contracts reach hundreds of kilobytes, so preloads that only + need address info (name, verification flags) should use this spec. Note that + `abi` is `nil` (not `%NotLoaded{}`) on structs loaded this way, so it must not + be used on preload paths feeding transaction input decoding. + """ + @spec association_without_abi() :: {:smart_contract, Ecto.Query.t()} + def association_without_abi do + {:smart_contract, + from(smart_contract in __MODULE__, select: struct(smart_contract, ^(__schema__(:fields) -- [:abi])))} + end + @typedoc """ The name of a parameter to a function or event. """ diff --git a/apps/explorer/lib/explorer/chain/transaction.ex b/apps/explorer/lib/explorer/chain/transaction.ex index adfc1933ca6..222eb558337 100644 --- a/apps/explorer/lib/explorer/chain/transaction.ex +++ b/apps/explorer/lib/explorer/chain/transaction.ex @@ -345,6 +345,7 @@ defmodule Explorer.Chain.Transaction do alias Explorer.Chain.Block.Reader.General, as: BlockReaderGeneral + alias Explorer.Chain.Cache.ContractMethods, as: ContractMethodsCache alias Explorer.Chain.Cache.Transactions alias Explorer.Chain.SmartContract.Proxy.Models.Implementation @@ -1092,13 +1093,15 @@ defmodule Explorer.Chain.Transaction do end end - defp decode_function_call_via_sig_provider(%{bytes: data} = input, hash, skip_sig_provider?) do + defp decode_function_call_via_sig_provider( + %{bytes: <> = data} = input, + hash, + skip_sig_provider? + ) do with true <- SigProviderInterface.enabled?(), false <- skip_sig_provider?, - {:ok, result} <- SigProviderInterface.decode_function_call(input), - true <- is_list(result), - false <- Enum.empty?(result), - abi <- [result |> List.first() |> Map.put("outputs", []) |> Map.put("type", "function")], + [_ | _] = abi <- + ContractMethodsCache.fetch_sig_provider_abi(method_id, fn -> request_abi_from_sig_provider(input) end), {:ok, _, _, _} = candidate <- do_decoded_input_data(data, abi, hash) do [candidate] else @@ -1107,6 +1110,19 @@ defmodule Explorer.Chain.Transaction do end end + # inputs shorter than a 4-byte method id cannot be a function call + defp decode_function_call_via_sig_provider(_input, _hash, _skip_sig_provider?), do: [] + + defp request_abi_from_sig_provider(input) do + with {:ok, result} <- SigProviderInterface.decode_function_call(input), + true <- is_list(result), + false <- Enum.empty?(result) do + [result |> List.first() |> Map.put("outputs", []) |> Map.put("type", "function")] + else + _ -> [] + end + end + defp check_methods_cache(method_id, methods_map, options) do Map.get_lazy(methods_map, method_id, fn -> method_id @@ -2387,7 +2403,7 @@ defmodule Explorer.Chain.Transaction do _ -> [] end) |> Enum.uniq() - |> ContractMethod.find_contract_methods(opts) + |> ContractMethodsCache.find_contract_methods(opts) |> Enum.into(empty_methods_map, &{&1.identifier, [&1]}) # decode remaining transaction using methods map From 9fb10eb899b6f2e720ae4d7057cfbe4672322072 Mon Sep 17 00:00:00 2001 From: Qwerty5Uiop <105209995+Qwerty5Uiop@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:05:03 +0400 Subject: [PATCH 12/18] fix: Fix state changes for sponsored transactions (#14702) --- .../transaction_interpretation.ex | 78 +++++--- .../models/transaction_state_helper.ex | 92 +++++++++- .../schemas/api/v2/eden/call.ex | 18 ++ .../schemas/api/v2/transaction.ex | 15 +- .../transaction/summary_just_request_body.ex | 18 ++ .../block_scout_web/views/api/v2/eden_view.ex | 35 +++- .../views/transaction_state_view.ex | 4 +- .../api/v2/transaction_controller_test.exs | 168 ++++++++++++++++++ .../lib/explorer/chain/transaction.ex | 34 ++++ .../chain/transaction/state_change.ex | 71 +++++++- .../test/explorer/chain/transaction_test.exs | 32 ++++ .../transform/address_coin_balances.ex | 40 ++++- .../lib/indexer/transform/addresses.ex | 41 +++++ .../transform/address_coin_balances_test.exs | 74 ++++++++ .../{addresses.exs => addresses_test.exs} | 53 ++++++ 15 files changed, 715 insertions(+), 58 deletions(-) create mode 100644 apps/block_scout_web/lib/block_scout_web/schemas/api/v2/eden/call.ex rename apps/indexer/test/indexer/transform/{addresses.exs => addresses_test.exs} (84%) diff --git a/apps/block_scout_web/lib/block_scout_web/microservice_interfaces/transaction_interpretation.ex b/apps/block_scout_web/lib/block_scout_web/microservice_interfaces/transaction_interpretation.ex index bf1f280112d..5b53275d17e 100644 --- a/apps/block_scout_web/lib/block_scout_web/microservice_interfaces/transaction_interpretation.ex +++ b/apps/block_scout_web/lib/block_scout_web/microservice_interfaces/transaction_interpretation.ex @@ -4,6 +4,8 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do Module to interact with Transaction Interpretation Service """ + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + import BlockScoutWeb.Chain, only: [transaction_to_internal_transactions: 2] alias BlockScoutWeb.API.V2.{Helper, InternalTransactionView, TokenTransferView, TokenView, TransactionView} @@ -142,12 +144,15 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do defp prepare_request_body(transaction) do transaction = - Chain.select_repo(@api_true).preload(transaction, [ - :block, - to_address: [:scam_badge, :names, :smart_contract], - from_address: [:scam_badge, :names, :smart_contract], - created_contract_address: [:scam_badge, :names, :token, :smart_contract] - ]) + Chain.select_repo(@api_true).preload( + transaction, + [ + :block, + to_address: [:scam_badge, :names, :smart_contract], + from_address: [:scam_badge, :names, :smart_contract], + created_contract_address: [:scam_badge, :names, :token, :smart_contract] + ] ++ chain_type_address_preloads() + ) token_transfers = transaction |> fetch_token_transfers() |> Enum.reverse() internal_transactions = transaction |> fetch_internal_transactions() |> Enum.reverse() @@ -181,32 +186,51 @@ defmodule BlockScoutWeb.MicroserviceInterfaces.TransactionInterpretation do decoded_input_data = decoded_input |> Transaction.format_decoded_input() |> TransactionView.decoded_input() %{ - data: %{ - to: - Helper.address_with_info(nil, transaction_with_meta.to_address, transaction_with_meta.to_address_hash, true), - from: - Helper.address_with_info( - nil, - transaction_with_meta.from_address, - transaction_with_meta.from_address_hash, - true - ), - hash: transaction_with_meta.hash, - type: transaction_with_meta.type, - value: transaction_with_meta.value, - method: Transaction.method_name(transaction_with_meta, Transaction.format_decoded_input(decoded_input)), - status: transaction_with_meta.status, - transaction_types: TransactionView.transaction_types(transaction_with_meta), - raw_input: transaction_with_meta.input, - decoded_input: decoded_input_data, - token_transfers: prepare_token_transfers(token_transfers_with_meta, decoded_input), - internal_transactions: prepare_internal_transactions(internal_transactions_with_meta, transaction_with_meta) - }, + data: + %{ + to: + Helper.address_with_info(nil, transaction_with_meta.to_address, transaction_with_meta.to_address_hash, true), + from: + Helper.address_with_info( + nil, + transaction_with_meta.from_address, + transaction_with_meta.from_address_hash, + true + ), + hash: transaction_with_meta.hash, + type: transaction_with_meta.type, + value: transaction_with_meta.value, + method: Transaction.method_name(transaction_with_meta, Transaction.format_decoded_input(decoded_input)), + status: transaction_with_meta.status, + transaction_types: TransactionView.transaction_types(transaction_with_meta), + raw_input: transaction_with_meta.input, + decoded_input: decoded_input_data, + token_transfers: prepare_token_transfers(token_transfers_with_meta, decoded_input), + internal_transactions: prepare_internal_transactions(internal_transactions_with_meta, transaction_with_meta) + } + |> extend_data_with_chain_type_fields(transaction_with_meta), logs_data: %{items: prepare_logs(logs_with_meta, transaction_with_meta)}, chain_id: :block_scout_web |> Application.get_env(:chain_id) |> ExplorerHelper.parse_integer() } end + @spec extend_data_with_chain_type_fields(map(), Transaction.t()) :: map() + case @chain_type do + :eden -> + defp chain_type_address_preloads, + do: [fee_payer_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] + + defp extend_data_with_chain_type_fields(data, transaction) do + # credo:disable-for-next-line Credo.Check.Design.AliasUsage + BlockScoutWeb.API.V2.EdenView.extend_transaction_interpretation_request(data, transaction) + end + + _ -> + defp chain_type_address_preloads, do: [] + + defp extend_data_with_chain_type_fields(data, _transaction), do: data + end + defp fetch_token_transfers(transaction) do full_options = [ diff --git a/apps/block_scout_web/lib/block_scout_web/models/transaction_state_helper.ex b/apps/block_scout_web/lib/block_scout_web/models/transaction_state_helper.ex index 137c7707534..bf43d1705f6 100644 --- a/apps/block_scout_web/lib/block_scout_web/models/transaction_state_helper.ex +++ b/apps/block_scout_web/lib/block_scout_web/models/transaction_state_helper.ex @@ -4,6 +4,8 @@ defmodule BlockScoutWeb.Models.TransactionStateHelper do Transaction state changes related functions """ + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + import Explorer.Chain.Address.Reputation, only: [reputation_association: 0] import Explorer.PagingOptions, only: [default_paging_options: 0] import Explorer.Chain.SmartContract, only: [burn_address_hash_string: 0] @@ -81,14 +83,16 @@ defmodule BlockScoutWeb.Models.TransactionStateHelper do block_transactions |> Enum.find(&(&1.hash == transaction.hash)) |> Repo.preload( - token_transfers: [ - token: reputation_association(), + [ + token_transfers: [ + token: reputation_association(), + from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], + to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()] + ], + block: [miner: [:names, :smart_contract, proxy_implementations_association()]], from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()] - ], - block: [miner: [:names, :smart_contract, proxy_implementations_association()]], - from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], - to_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()] + ] ++ chain_type_address_preloads() ) |> Transaction.preload_internal_transactions( from_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()], @@ -115,8 +119,7 @@ defmodule BlockScoutWeb.Models.TransactionStateHelper do end defp transaction_to_coin_balances(transaction, previous_block_number, options) do - Enum.reduce( - transaction.internal_transactions, + initial_coin_balances = %{ transaction.from_address_hash => {transaction.from_address, coin_balance(transaction.from_address_hash, previous_block_number, options)}, @@ -124,11 +127,82 @@ defmodule BlockScoutWeb.Models.TransactionStateHelper do {transaction.to_address, coin_balance(transaction.to_address_hash, previous_block_number, options)}, transaction.block.miner_hash => {transaction.block.miner, coin_balance(transaction.block.miner_hash, previous_block_number, options)} - }, + } + |> put_fee_payer_coin_balance(transaction, previous_block_number, options) + |> put_calls_recipients_coin_balances(transaction, previous_block_number, options) + + Enum.reduce( + transaction.internal_transactions, + initial_coin_balances, &internal_transaction_to_coin_balances(&1, previous_block_number, options, &2) ) end + if @chain_type == :eden do + defp put_fee_payer_coin_balance( + coin_balances, + %Transaction{fee_payer_address_hash: fee_payer_address_hash} = transaction, + previous_block_number, + options + ) + when not is_nil(fee_payer_address_hash) do + Map.put_new_lazy(coin_balances, fee_payer_address_hash, fn -> + {transaction.fee_payer_address, coin_balance(fee_payer_address_hash, previous_block_number, options)} + end) + end + end + + defp put_fee_payer_coin_balance(coin_balances, _transaction, _previous_block_number, _options), do: coin_balances + + if @chain_type == :eden do + alias Explorer.Chain.Address + + defp put_calls_recipients_coin_balances(coin_balances, transaction, previous_block_number, options) do + transaction + |> Transaction.calls_value_by_recipient() + |> Map.keys() + |> Enum.reject(&Map.has_key?(coin_balances, &1)) + |> case do + [] -> + coin_balances + + address_hashes -> + addresses = hashes_to_addresses(address_hashes, options) + + Enum.reduce(address_hashes, coin_balances, fn address_hash, acc -> + Map.put( + acc, + address_hash, + {addresses[address_hash] || %Address{hash: address_hash}, + coin_balance(address_hash, previous_block_number, options)} + ) + end) + end + end + + defp hashes_to_addresses(address_hashes, options) do + address_hashes + |> Chain.hashes_to_addresses(options) + |> Chain.select_repo(options).preload([ + :scam_badge, + :names, + :smart_contract, + proxy_implementations_association() + ]) + |> Map.new(&{&1.hash, &1}) + end + else + defp put_calls_recipients_coin_balances(coin_balances, _transaction, _previous_block_number, _options), + do: coin_balances + end + + if @chain_type == :eden do + defp chain_type_address_preloads, + do: [fee_payer_address: [:scam_badge, :names, :smart_contract, proxy_implementations_association()]] + else + defp chain_type_address_preloads, do: [] + end + defp internal_transaction_to_coin_balances( %InternalTransaction{call_type: call_type, call_type_enum: call_type_enum}, _, diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/eden/call.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/eden/call.ex new file mode 100644 index 00000000000..de04fcad897 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/eden/call.ex @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: LicenseRef-Blockscout +defmodule BlockScoutWeb.Schemas.API.V2.Eden.Call do + @moduledoc false + require OpenApiSpex + alias BlockScoutWeb.Schemas.API.V2.General + + OpenApiSpex.schema(%{ + type: :object, + nullable: false, + properties: %{ + to: General.AddressHashNullable, + value: General.IntegerString, + input: General.HexString + }, + required: [:to, :value, :input], + additionalProperties: false + }) +end diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction.ex index 0033c5e4cc8..1322be3f59d 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction.ex @@ -3,6 +3,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.ChainTypeCustomizations do @moduledoc false alias BlockScoutWeb.API.V2.ZkSyncView alias BlockScoutWeb.Schemas.API.V2.{Address, General, Token} + alias BlockScoutWeb.Schemas.API.V2.Eden.Call, as: EdenCall alias BlockScoutWeb.Schemas.API.V2.Transaction.Fee alias BlockScoutWeb.Schemas.Helper alias OpenApiSpex.Schema @@ -142,18 +143,6 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.ChainTypeCustomizations do additionalProperties: false } - @eden_call_schema %Schema{ - type: :object, - nullable: false, - properties: %{ - to: General.AddressHashNullable, - value: General.IntegerString, - input: General.HexString - }, - required: [:to, :value, :input], - additionalProperties: false - } - @doc """ Applies chain-specific field customizations to the given schema based on the configured chain type. @@ -216,7 +205,7 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.ChainTypeCustomizations do |> Helper.extend_schema( properties: %{ fee_payer: %Schema{allOf: [Address], nullable: true}, - calls: %Schema{type: :array, items: @eden_call_schema, nullable: true} + calls: %Schema{type: :array, items: EdenCall, nullable: true} }, required: [:fee_payer, :calls] ) diff --git a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/summary_just_request_body.ex b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/summary_just_request_body.ex index ccbf520583a..e9af52cf66b 100644 --- a/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/summary_just_request_body.ex +++ b/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/transaction/summary_just_request_body.ex @@ -5,7 +5,11 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.SummaryJustRequestBody do """ require OpenApiSpex + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + alias BlockScoutWeb.Schemas.API.V2.{Address, InternalTransaction, Log, TokenTransfer} + alias BlockScoutWeb.Schemas.API.V2.Eden.Call, as: EdenCall + alias BlockScoutWeb.Schemas.Helper alias OpenApiSpex.Schema logs_data_schema = %Schema{ @@ -50,6 +54,20 @@ defmodule BlockScoutWeb.Schemas.API.V2.Transaction.SummaryJustRequestBody do additionalProperties: false } + data_schema = + case @chain_type do + :eden -> + Helper.extend_schema(data_schema, + properties: %{ + fee_payer: %Schema{allOf: [Address], nullable: true}, + calls: %Schema{type: :array, items: EdenCall, nullable: true} + } + ) + + _ -> + data_schema + end + OpenApiSpex.schema(%{ type: :object, required: [:data], diff --git a/apps/block_scout_web/lib/block_scout_web/views/api/v2/eden_view.ex b/apps/block_scout_web/lib/block_scout_web/views/api/v2/eden_view.ex index 7b347627c3d..388963f63eb 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/api/v2/eden_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/api/v2/eden_view.ex @@ -49,12 +49,40 @@ defmodule BlockScoutWeb.API.V2.EdenView do |> Map.put("calls", prepare_calls(transaction.calls, single_transaction?)) end + @doc """ + Extends the body of the transaction interpretation request with the Eden-specific fields. + + Without the calls the service has no way to tell that the transaction is a batch: its `to` and + `value` are the compatibility fields derived from the first call and from the sum of all the + calls, and the batched calls produce no internal transactions. + + ## Parameters + - `data`: A map defining the `data` part of the request body which will be extended. + - `transaction`: The transaction structure. + + ## Returns + - A map extended with the data related to Eden. + """ + @spec extend_transaction_interpretation_request(map(), Transaction.t()) :: map() + def extend_transaction_interpretation_request(data, %Transaction{} = transaction) do + data + |> Map.put( + :fee_payer, + APIHelper.address_with_info(nil, transaction.fee_payer_address, transaction.fee_payer_address_hash, false) + ) + |> Map.put(:calls, prepare_calls(transaction.calls)) + end + @spec prepare_calls(term(), boolean()) :: [map()] | nil - defp prepare_calls(calls, true = _single_transaction?) when is_list(calls), - do: Enum.map(calls, &prepare_call/1) + defp prepare_calls(calls, true = _single_transaction?), do: prepare_calls(calls) defp prepare_calls(_calls, _single_transaction?), do: nil + @spec prepare_calls(term()) :: [map()] | nil + defp prepare_calls(calls) when is_list(calls), do: Enum.map(calls, &prepare_call/1) + + defp prepare_calls(_calls), do: nil + # A call is stored as it comes from the JSON RPC response: `to` is a plain address string which # is `nil` for a contract creation call, `value` is an integer and `input` is a hex string. # Renders it the way the rest of the API v2 renders those types: a checksummed address and a @@ -74,5 +102,8 @@ defmodule BlockScoutWeb.API.V2.EdenView do else def extend_transaction_json_response(out_json, _, _, _, _), do: out_json + + def extend_transaction_interpretation_request(data, _), + do: data end end diff --git a/apps/block_scout_web/lib/block_scout_web/views/transaction_state_view.ex b/apps/block_scout_web/lib/block_scout_web/views/transaction_state_view.ex index 2c6db6e6d91..e4a20d908d8 100644 --- a/apps/block_scout_web/lib/block_scout_web/views/transaction_state_view.ex +++ b/apps/block_scout_web/lib/block_scout_web/views/transaction_state_view.ex @@ -5,7 +5,7 @@ defmodule BlockScoutWeb.TransactionStateView do alias Explorer.Chain alias Explorer.Chain.{Address, Wei} - import Explorer.Chain.Transaction.StateChange, only: [from_loss: 1, has_diff?: 1, to_profit: 1] + import Explorer.Chain.Transaction.StateChange, only: [fee_payer_loss: 1, from_loss: 1, has_diff?: 1, to_profit: 1] def not_negative?(%Wei{value: val}) do not Decimal.negative?(val) @@ -24,7 +24,7 @@ defmodule BlockScoutWeb.TransactionStateView do end def has_state_changes?(transaction) do - has_diff?(from_loss(transaction)) or has_diff?(to_profit(transaction)) + has_diff?(from_loss(transaction)) or has_diff?(to_profit(transaction)) or has_diff?(fee_payer_loss(transaction)) end def display_value(balance, :coin, _token_id) do diff --git a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs index 1f021d8bce4..8c4338e39cf 100644 --- a/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs +++ b/apps/block_scout_web/test/block_scout_web/controllers/api/v2/transaction_controller_test.exs @@ -3447,6 +3447,174 @@ defmodule BlockScoutWeb.API.V2.TransactionControllerTest do assert item["calls"] == nil assert "sponsored_transaction" in item["transaction_types"] end + + test "attributes the fee state change to the fee payer instead of the sender", %{conn: conn} do + block_before = insert(:block) + fee_payer = insert(:address) + recipient = insert(:address) + + transaction = + :transaction + |> insert( + type: 118, + fee_payer_address_hash: fee_payer.hash, + calls: [%{"to" => to_string(recipient.hash), "value" => 0, "input" => "0xdeadbeef"}], + to_address: recipient, + gas_price: 1_000, + value: 0 + ) + |> with_block(status: :ok, gas_used: 147) + + insert_coin_balances_before(transaction, [fee_payer, recipient], block_before) + + request = get(conn, "/api/v2/transactions/#{transaction.hash}/state-changes") + + assert response = json_response(request, 200) + + changes = + Map.new(response["items"], fn item -> {item["address"]["hash"], item["change"]} end) + + assert changes[Address.checksum(fee_payer.hash)] == "-147000" + + refute Map.has_key?(changes, Address.checksum(transaction.from_address_hash)) + + assert changes[Address.checksum(transaction.block.miner_hash)] == "147000" + end + + test "credits every recipient of the batched calls with its own value", %{conn: conn} do + block_before = insert(:block) + fee_payer = insert(:address) + recipient_a = insert(:address) + recipient_b = insert(:address) + + transaction = + :transaction + |> insert( + type: 118, + fee_payer_address_hash: fee_payer.hash, + calls: [ + %{"to" => to_string(recipient_a.hash), "value" => 1, "input" => "0x"}, + %{"to" => to_string(recipient_b.hash), "value" => 2, "input" => "0x"} + ], + to_address: recipient_a, + value: 3, + gas_price: 1_000 + ) + |> with_block(status: :ok, gas_used: 147) + + insert_coin_balances_before(transaction, [fee_payer, recipient_a, recipient_b], block_before) + + request = get(conn, "/api/v2/transactions/#{transaction.hash}/state-changes") + + assert response = json_response(request, 200) + + changes = + Map.new(response["items"], fn item -> {item["address"]["hash"], item["change"]} end) + + assert changes[Address.checksum(fee_payer.hash)] == "-147000" + assert changes[Address.checksum(transaction.from_address_hash)] == "-3" + assert changes[Address.checksum(recipient_a.hash)] == "1" + assert changes[Address.checksum(recipient_b.hash)] == "2" + end + + test "sums up the batched calls sharing the same recipient", %{conn: conn} do + block_before = insert(:block) + fee_payer = insert(:address) + recipient = insert(:address) + + transaction = + :transaction + |> insert( + type: 118, + fee_payer_address_hash: fee_payer.hash, + calls: [ + %{"to" => to_string(recipient.hash), "value" => 1, "input" => "0x"}, + %{"to" => to_string(recipient.hash), "value" => 2, "input" => "0x"} + ], + to_address: recipient, + value: 3, + gas_price: 1_000 + ) + |> with_block(status: :ok, gas_used: 147) + + insert_coin_balances_before(transaction, [fee_payer, recipient], block_before) + + request = get(conn, "/api/v2/transactions/#{transaction.hash}/state-changes") + + assert response = json_response(request, 200) + + changes = + Map.new(response["items"], fn item -> {item["address"]["hash"], item["change"]} end) + + assert changes[Address.checksum(transaction.from_address_hash)] == "-3" + assert changes[Address.checksum(recipient.hash)] == "3" + end + + test "credits the recipients of the calls which have no address indexed yet", %{conn: conn} do + block_before = insert(:block) + fee_payer = insert(:address) + recipient_hash = "0x11f60a633dd30a8d1a26dd6e20167a9293fb4647" + + transaction = + :transaction + |> insert( + type: 118, + fee_payer_address_hash: fee_payer.hash, + calls: [%{"to" => recipient_hash, "value" => 3, "input" => "0x"}], + value: 3, + gas_price: 1_000 + ) + |> with_block(status: :ok, gas_used: 147) + + insert_coin_balances_before(transaction, [fee_payer], block_before) + + request = get(conn, "/api/v2/transactions/#{transaction.hash}/state-changes") + + assert response = json_response(request, 200) + + changes = + Map.new(response["items"], fn item -> {item["address"]["hash"], item["change"]} end) + + assert changes[Address.checksum(recipient_hash)] == "3" + end + + test "charges the sender for both the value and the fee for regular transactions", %{conn: conn} do + block_before = insert(:block) + + transaction = + :transaction + |> insert(type: 2, gas_price: 1_000, value: 3) + |> with_block(status: :ok, gas_used: 147) + + insert_coin_balances_before(transaction, [], block_before) + + request = get(conn, "/api/v2/transactions/#{transaction.hash}/state-changes") + + assert response = json_response(request, 200) + + changes = + Map.new(response["items"], fn item -> {item["address"]["hash"], item["change"]} end) + + assert changes[Address.checksum(transaction.from_address_hash)] == "-147003" + assert changes[Address.checksum(transaction.to_address_hash)] == "3" + end + end + + defp insert_coin_balances_before(transaction, extra_addresses, block_before) do + [ + {transaction.from_address, transaction.from_address_hash}, + {transaction.to_address, transaction.to_address_hash}, + {transaction.block.miner, transaction.block.miner_hash} + ] + |> Enum.concat(Enum.map(extra_addresses, &{&1, &1.hash})) + |> Enum.uniq_by(fn {_address, address_hash} -> address_hash end) + |> Enum.each(fn {address, address_hash} -> + insert(:address_coin_balance, + address: address, + address_hash: address_hash, + block_number: block_before.number + ) + end) end end diff --git a/apps/explorer/lib/explorer/chain/transaction.ex b/apps/explorer/lib/explorer/chain/transaction.ex index 222eb558337..8ee5a01ffa4 100644 --- a/apps/explorer/lib/explorer/chain/transaction.ex +++ b/apps/explorer/lib/explorer/chain/transaction.ex @@ -2263,6 +2263,40 @@ defmodule Explorer.Chain.Transaction do |> Decimal.mult(gas_used) end + @doc """ + Aggregates the values of the calls batched in an Eden sponsored transaction by their recipients. + + The `to_address_hash` and the `value` of such a transaction are the compatibility fields derived + from the first call and from the sum of all the calls respectively, so the calls are the only + source of the actual recipients and of the amounts they receive. + + The calls without a recipient (the contract creations) and the malformed ones are skipped. + + ## Parameters + - `transaction`: The transaction entity. + + ## Returns + - A map of the recipient address hashes to the total value each of them receives within the + transaction. Empty for the transactions which are not the sponsored ones. + """ + @spec calls_value_by_recipient(__MODULE__.t()) :: %{Hash.Address.t() => Wei.t()} + if @chain_type == :eden do + def calls_value_by_recipient(%__MODULE__{calls: calls}) when is_list(calls) do + Enum.reduce(calls, %{}, fn call, acc -> + with {:ok, address_hash} <- call |> Map.get("to") |> Hash.Address.cast(), + {:ok, value} <- call |> Map.get("value") |> Wei.cast() do + Map.update(acc, address_hash, value, &Wei.sum(&1, value)) + else + _ -> acc + end + end) + end + + def calls_value_by_recipient(%__MODULE__{}), do: %{} + else + def calls_value_by_recipient(%__MODULE__{}), do: %{} + end + @doc """ Calculates burnt fees for a transaction as `base_fee_per_gas * gas_used`. diff --git a/apps/explorer/lib/explorer/chain/transaction/state_change.ex b/apps/explorer/lib/explorer/chain/transaction/state_change.ex index e1d878e41a9..8fe2e18bb6d 100644 --- a/apps/explorer/lib/explorer/chain/transaction/state_change.ex +++ b/apps/explorer/lib/explorer/chain/transaction/state_change.ex @@ -4,6 +4,8 @@ defmodule Explorer.Chain.Transaction.StateChange do Helper functions and struct for storing state changes """ + use Utils.CompileTimeEnvHelper, chain_type: [:explorer, :chain_type] + use Utils.RuntimeEnvHelper, miner_gets_burnt_fees?: [:explorer, [Explorer.Chain.Transaction, :block_miner_gets_burnt_fees?]] @@ -59,7 +61,8 @@ defmodule Explorer.Chain.Transaction.StateChange do coin_balances = coin_balances |> update_balance(transaction.from_address_hash, &Wei.sub(&1, from_loss(transaction))) - |> update_balance(transaction.to_address_hash, &Wei.sum(&1, to_profit(transaction))) + |> update_balance(fee_payer_address_hash(transaction), &Wei.sub(&1, fee_payer_loss(transaction))) + |> credit_recipients(transaction) |> update_balance(block.miner_hash, &Wei.sum(&1, miner_profit(transaction, block))) if error?(transaction) do @@ -70,6 +73,30 @@ defmodule Explorer.Chain.Transaction.StateChange do end end + # Credits the addresses which receive the value of the transaction. + # + # An Eden sponsored transaction carries an ordered list of the batched calls, each one with its + # own recipient and value, so every one of them is credited separately. The `to_address_hash` and + # the `value` of such a transaction are just the compatibility fields derived from the first call + # and from the sum of all the calls, hence they are not used here. + if @chain_type == :eden do + defp credit_recipients(coin_balances, %Transaction{calls: [_ | _]} = transaction) do + if error?(transaction) do + coin_balances + else + transaction + |> Transaction.calls_value_by_recipient() + |> Enum.reduce(coin_balances, fn {address_hash, value}, acc -> + update_balance(acc, address_hash, &Wei.sum(&1, value)) + end) + end + end + end + + defp credit_recipients(coin_balances, transaction) do + update_balance(coin_balances, transaction.to_address_hash, &Wei.sum(&1, to_profit(transaction))) + end + defp update_coin_balances_from_internal_transaction( %InternalTransaction{call_type: call_type, call_type_enum: call_type_enum}, coin_balances @@ -199,12 +226,12 @@ defmodule Explorer.Chain.Transaction.StateChange do """ @spec from_loss(Transaction.t() | InternalTransaction.t()) :: Wei.t() def from_loss(%Transaction{} = transaction) do - {_, fee} = Transaction.fee(transaction, :wei) + fee = sender_fee(transaction) if error?(transaction) do - %Wei{value: fee} + fee else - Wei.sum(transaction.value, %Wei{value: fee}) + Wei.sum(transaction.value, fee) end end @@ -212,6 +239,42 @@ defmodule Explorer.Chain.Transaction.StateChange do transaction.value || Wei.zero() end + @doc """ + Returns the balance change of the fee payer (sponsor) of a transaction. + + Equals to the transaction fee for the transactions which fee is paid by an address other than + the transaction sender (the Eden sponsored transactions), zero otherwise. + """ + @spec fee_payer_loss(Transaction.t()) :: Wei.t() + if @chain_type == :eden do + def fee_payer_loss(%Transaction{fee_payer_address_hash: nil}), do: Wei.zero() + + def fee_payer_loss(%Transaction{} = transaction), do: transaction_fee(transaction) + else + def fee_payer_loss(%Transaction{}), do: Wei.zero() + end + + if @chain_type == :eden do + defp sender_fee(%Transaction{fee_payer_address_hash: nil} = transaction), do: transaction_fee(transaction) + + defp sender_fee(%Transaction{}), do: Wei.zero() + else + defp sender_fee(%Transaction{} = transaction), do: transaction_fee(transaction) + end + + if @chain_type == :eden do + defp fee_payer_address_hash(%Transaction{fee_payer_address_hash: fee_payer_address_hash}), + do: fee_payer_address_hash + else + defp fee_payer_address_hash(%Transaction{}), do: nil + end + + defp transaction_fee(transaction) do + {_, fee} = Transaction.fee(transaction, :wei) + + %Wei{value: fee} + end + @doc """ Returns the balance change of to address of a transaction or an internal transaction. diff --git a/apps/explorer/test/explorer/chain/transaction_test.exs b/apps/explorer/test/explorer/chain/transaction_test.exs index 469dd8daf34..22e4faacae5 100644 --- a/apps/explorer/test/explorer/chain/transaction_test.exs +++ b/apps/explorer/test/explorer/chain/transaction_test.exs @@ -923,6 +923,38 @@ defmodule Explorer.Chain.TransactionTest do end end + if Application.compile_env(:explorer, :chain_type) == :eden do + describe "calls_value_by_recipient/1" do + test "sums up the values of the batched calls per recipient" do + first_recipient = "0x11f60a633dd30a8d1a26dd6e20167a9293fb4647" + second_recipient = "0xcfc096e58b1f858e5a3ee88ecaeccb2b464625b5" + + transaction = %Transaction{ + calls: [ + %{"to" => first_recipient, "value" => 1, "input" => "0x"}, + %{"to" => second_recipient, "value" => 2, "input" => "0x"}, + %{"to" => first_recipient, "value" => 3, "input" => "0x"} + ] + } + + assert %{} = values = Transaction.calls_value_by_recipient(transaction) + + assert Map.new(values, fn {address_hash, value} -> {to_string(address_hash), to_string(value.value)} end) == + %{first_recipient => "4", second_recipient => "2"} + end + + test "skips the calls without a recipient" do + transaction = %Transaction{calls: [%{"to" => nil, "value" => 1, "input" => "0x"}]} + + assert Transaction.calls_value_by_recipient(transaction) == %{} + end + + test "returns an empty map for the transactions which are not sponsored ones" do + assert Transaction.calls_value_by_recipient(%Transaction{calls: nil}) == %{} + end + end + end + describe "get_method_name/1" do test "returns method name for transaction with input data starting with 0x" do transaction = diff --git a/apps/indexer/lib/indexer/transform/address_coin_balances.ex b/apps/indexer/lib/indexer/transform/address_coin_balances.ex index 50175ec99dd..2d33b2a7218 100644 --- a/apps/indexer/lib/indexer/transform/address_coin_balances.ex +++ b/apps/indexer/lib/indexer/transform/address_coin_balances.ex @@ -4,7 +4,9 @@ defmodule Indexer.Transform.AddressCoinBalances do Extracts `Explorer.Chain.Address.CoinBalance` params from other schema's params. """ - use Utils.CompileTimeEnvHelper, chain_identity: [:explorer, :chain_identity] + use Utils.CompileTimeEnvHelper, + chain_identity: [:explorer, :chain_identity], + chain_type: [:explorer, :chain_type] use Utils.RuntimeEnvHelper, chain_type: [:explorer, :chain_type], @@ -241,5 +243,41 @@ defmodule Indexer.Transform.AddressCoinBalances do end end + if @chain_type == :eden do + defp transactions_params_chain_type_fields_reducer( + %{block_number: block_number} = transaction_params, + initial + ) + when is_integer(block_number) do + initial + |> put_fee_payer(transaction_params, block_number) + |> put_calls_recipients(transaction_params, block_number) + end + end + defp transactions_params_chain_type_fields_reducer(_, acc), do: acc + + if @chain_type == :eden do + alias Explorer.Chain + + defp put_fee_payer(acc, %{fee_payer_address_hash: fee_payer_address_hash}, block_number) + when is_binary(fee_payer_address_hash) do + MapSet.put(acc, %{address_hash: fee_payer_address_hash, block_number: block_number}) + end + + defp put_fee_payer(acc, _transaction_params, _block_number), do: acc + + defp put_calls_recipients(acc, %{calls: calls}, block_number) when is_list(calls) do + Enum.reduce(calls, acc, fn call, inner_acc -> + address_hash = Map.get(call, "to") + + case Chain.string_to_address_hash(address_hash) do + {:ok, _} -> MapSet.put(inner_acc, %{address_hash: address_hash, block_number: block_number}) + :error -> inner_acc + end + end) + end + + defp put_calls_recipients(acc, _transaction_params, _block_number), do: acc + end end diff --git a/apps/indexer/lib/indexer/transform/addresses.ex b/apps/indexer/lib/indexer/transform/addresses.ex index 48ddf8b8dc4..c0e021573ca 100644 --- a/apps/indexer/lib/indexer/transform/addresses.ex +++ b/apps/indexer/lib/indexer/transform/addresses.ex @@ -569,6 +569,7 @@ defmodule Indexer.Transform.Addresses do addresses |> List.flatten() + |> Enum.concat(chain_type_addresses(fetched_data, state)) |> merge_addresses() end @@ -577,6 +578,46 @@ defmodule Indexer.Transform.Addresses do def extract_addresses_from_item(item, fields, state), do: Enum.flat_map(fields, &extract_fields(&1, item, state)) + if @chain_type == :eden do + alias Explorer.Chain + + @eden_call_fields [ + [ + %{from: :block_number, to: :fetched_coin_balance_block_number}, + %{from: :to_address_hash, to: :hash} + ] + ] + + # The recipients of the calls batched in an Eden sponsored transaction are stored in the `calls` + # JSON field, so they can't be declared in `@entity_to_address_map`, which supports the plain + # fields only. The calls are flattened into the items of the shape the declarations expect + # instead, so that the pending transactions keep being handled the same way. + defp chain_type_addresses(fetched_data, state) do + fetched_data + |> Map.get(:transactions) + |> Kernel.||([]) + |> Enum.flat_map(&transaction_to_call_items/1) + |> extract_addresses_from_collection(@eden_call_fields, state) + end + + defp transaction_to_call_items(%{calls: calls} = transaction) when is_list(calls) do + block_number = Map.take(transaction, [:block_number]) + + Enum.flat_map(calls, fn call -> + to_address_hash = Map.get(call, "to") + + case Chain.string_to_address_hash(to_address_hash) do + {:ok, _} -> [Map.put(block_number, :to_address_hash, to_address_hash)] + :error -> [] + end + end) + end + + defp transaction_to_call_items(_transaction), do: [] + else + defp chain_type_addresses(_fetched_data, _state), do: [] + end + def merge_addresses(addresses) when is_list(addresses) do addresses |> Enum.group_by(fn address -> address.hash end) diff --git a/apps/indexer/test/indexer/transform/address_coin_balances_test.exs b/apps/indexer/test/indexer/transform/address_coin_balances_test.exs index 836789bc565..372d41295c7 100644 --- a/apps/indexer/test/indexer/transform/address_coin_balances_test.exs +++ b/apps/indexer/test/indexer/transform/address_coin_balances_test.exs @@ -247,6 +247,80 @@ defmodule Indexer.Transform.AddressCoinBalancesTest do end end + if Application.compile_env(:explorer, :chain_type) == :eden do + describe "params_set/1 transactions_params on Eden chain" do + test "with sponsored transaction extracts fee_payer_address_hash" do + block_number = 1 + + from_address_hash = to_string(Factory.address_hash()) + fee_payer_address_hash = to_string(Factory.address_hash()) + + transaction_params = + :transaction + |> Factory.params_for() + |> Map.put(:block_number, block_number) + |> Map.put(:from_address_hash, from_address_hash) + |> Map.put(:fee_payer_address_hash, fee_payer_address_hash) + + params_set = AddressCoinBalances.params_set(%{transactions_params: [transaction_params]}) + + assert MapSet.size(params_set) == 2 + assert MapSet.member?(params_set, %{address_hash: from_address_hash, block_number: block_number}) + assert MapSet.member?(params_set, %{address_hash: fee_payer_address_hash, block_number: block_number}) + end + + test "with sponsored transaction extracts the recipients of the batched calls" do + block_number = 1 + + from_address_hash = to_string(Factory.address_hash()) + fee_payer_address_hash = to_string(Factory.address_hash()) + first_call_address_hash = to_string(Factory.address_hash()) + second_call_address_hash = to_string(Factory.address_hash()) + + transaction_params = + :transaction + |> Factory.params_for() + |> Map.put(:block_number, block_number) + |> Map.put(:from_address_hash, from_address_hash) + |> Map.put(:fee_payer_address_hash, fee_payer_address_hash) + |> Map.put(:calls, [ + %{"to" => first_call_address_hash, "value" => 1, "input" => "0x"}, + %{"to" => second_call_address_hash, "value" => 2, "input" => "0x"}, + %{"to" => nil, "value" => 3, "input" => "0xc0ffee"}, + %{"to" => "0xdeadbeef", "value" => 4, "input" => "0x"}, + %{"to" => 12_345, "value" => 5, "input" => "0x"} + ]) + + params_set = AddressCoinBalances.params_set(%{transactions_params: [transaction_params]}) + + assert MapSet.size(params_set) == 4 + assert MapSet.member?(params_set, %{address_hash: from_address_hash, block_number: block_number}) + assert MapSet.member?(params_set, %{address_hash: fee_payer_address_hash, block_number: block_number}) + assert MapSet.member?(params_set, %{address_hash: first_call_address_hash, block_number: block_number}) + assert MapSet.member?(params_set, %{address_hash: second_call_address_hash, block_number: block_number}) + end + + test "with regular transaction extracts nothing extra" do + block_number = 1 + + from_address_hash = to_string(Factory.address_hash()) + + transaction_params = + :transaction + |> Factory.params_for() + |> Map.put(:block_number, block_number) + |> Map.put(:from_address_hash, from_address_hash) + |> Map.put(:fee_payer_address_hash, nil) + |> Map.put(:calls, nil) + + params_set = AddressCoinBalances.params_set(%{transactions_params: [transaction_params]}) + + assert MapSet.size(params_set) == 1 + assert MapSet.member?(params_set, %{address_hash: from_address_hash, block_number: block_number}) + end + end + end + if Application.compile_env(:explorer, :chain_type) == :arc do describe "params_set/1 logs_params on Arc chain" do test "with EIP-7708 Transfer queues non-burn from_address and to_address for coin balances" do diff --git a/apps/indexer/test/indexer/transform/addresses.exs b/apps/indexer/test/indexer/transform/addresses_test.exs similarity index 84% rename from apps/indexer/test/indexer/transform/addresses.exs rename to apps/indexer/test/indexer/transform/addresses_test.exs index df5eb0a2c7e..bdb28fdb247 100644 --- a/apps/indexer/test/indexer/transform/addresses.exs +++ b/apps/indexer/test/indexer/transform/addresses_test.exs @@ -272,6 +272,59 @@ defmodule Indexer.Transform.AddressesTest do ] }) == [] end + + if Application.compile_env(:explorer, :chain_type) == :eden do + test "recipients of the batched calls are extracted with the coin balance block number" do + first_call_hash = "0x11f60a633dd30a8d1a26dd6e20167a9293fb4647" + second_call_hash = "0xcfc096e58b1f858e5a3ee88ecaeccb2b464625b5" + + addresses = + Addresses.extract_addresses(%{ + transactions: [ + %{ + block_number: 34, + from_address_hash: "0x0000000000000000000000000000000000000001", + nonce: 12, + calls: [ + %{"to" => first_call_hash, "value" => 1, "input" => "0x"}, + %{"to" => second_call_hash, "value" => 2, "input" => "0x"}, + %{"to" => nil, "value" => 3, "input" => "0xc0ffee"} + ] + } + ] + }) + + assert Enum.find(addresses, &(&1.hash == first_call_hash)) == + %{hash: first_call_hash, fetched_coin_balance_block_number: 34} + + assert Enum.find(addresses, &(&1.hash == second_call_hash)) == + %{hash: second_call_hash, fetched_coin_balance_block_number: 34} + + refute Enum.any?(addresses, &is_nil(&1.hash)) + end + + test "recipients of the batched calls without a `block_number` aren't extracted" do + assert Addresses.extract_addresses(%{ + transactions: [ + %{calls: [%{"to" => "0x11f60a633dd30a8d1a26dd6e20167a9293fb4647", "value" => 1, "input" => "0x"}]} + ] + }) == [] + end + + test "recipients of the batched calls which aren't valid address hashes aren't extracted" do + assert Addresses.extract_addresses(%{ + transactions: [ + %{ + block_number: 34, + calls: [ + %{"to" => "0xdeadbeef", "value" => 1, "input" => "0x"}, + %{"to" => 12_345, "value" => 2, "input" => "0x"} + ] + } + ] + }) == [] + end + end end describe "extract_addresses_from_collection/2" do From 8a1cfbe875d05c94ac3a12ee6bb6b5a24ae2d3f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:17:04 +0300 Subject: [PATCH 13/18] chore(deps-dev): bump js-yaml from 4.3.0 to 4.3.1 in /types-package (#14674) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- types-package/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/types-package/package-lock.json b/types-package/package-lock.json index 6da19834f52..1b246604a3e 100644 --- a/types-package/package-lock.json +++ b/types-package/package-lock.json @@ -226,9 +226,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { From 85953e35b243aff4dbc2383d6d66cbe78f97e45f Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Mon, 17 Aug 2026 20:57:03 +0300 Subject: [PATCH 14/18] fix: degrade gracefully when microservice HTTP pool is exhausted (#14707) --- .../microservice_interfaces/http_client.ex | 34 ++++++++- .../http_client_test.exs | 70 ++++++++++++++----- config/runtime.exs | 3 +- cspell.json | 1 + docker-compose/envs/common-blockscout.env | 1 + 5 files changed, 90 insertions(+), 19 deletions(-) diff --git a/apps/explorer/lib/explorer/microservice_interfaces/http_client.ex b/apps/explorer/lib/explorer/microservice_interfaces/http_client.ex index 198bf2caeae..6822700bd72 100644 --- a/apps/explorer/lib/explorer/microservice_interfaces/http_client.ex +++ b/apps/explorer/lib/explorer/microservice_interfaces/http_client.ex @@ -70,9 +70,24 @@ defmodule Explorer.MicroserviceInterfaces.HttpClient do """ @spec pool_child_specs() :: [Supervisor.child_spec()] def pool_child_specs do + total_size = Application.get_env(:explorer, :microservice_http_pool_size) + pool_count = Application.get_env(:explorer, :microservice_http_pool_count) + pools = %{ default: [ - size: Application.get_env(:explorer, :microservice_http_pool_size), + # NimblePool is built for small pools, and per-pool size is the knob + # that keeps its process responsive. Two costs scale with it: every + # checkout/checkin is a message through the single pool process, and - + # much worse - every message an idle socket sends it (e.g. the remote + # closing an idle keep-alive connection) makes NimblePool run + # handle_info over EVERY idle worker, O(size) per message. A batch of + # idle-connection closes against a big pool can occupy its process for + # long enough that checkout replies miss the pool timeout even though + # almost all connections are free. Keep per-pool size around Finch's + # default of 50 by raising MICROSERVICE_HTTP_POOL_COUNT rather than + # letting pools grow. + size: max(div(total_size, pool_count), 1), + count: pool_count, # metrics make the pools observable in a remote console via # Finch.get_pool_status/2, e.g. when debugging checkout timeouts start_pool_metrics?: true @@ -83,13 +98,28 @@ defmodule Explorer.MicroserviceInterfaces.HttpClient do end defp request(method, url, body, headers, options, finch_name, default_checkout_timeout) do + # :finch_instance is a test seam: it lets tests exercise this function + # against a small pool they control instead of the app-wide instances adapter_options = - [name: finch_name, pool_timeout: options[:checkout_timeout] || default_checkout_timeout] + [ + name: options[:finch_instance] || finch_name, + pool_timeout: options[:checkout_timeout] || default_checkout_timeout + ] |> put_receive_timeout(options[:recv_timeout]) [method: method, url: url, body: body, headers: headers, query: options[:params] || []] |> then(&Tesla.request(client(adapter_options), &1)) |> parse_response() + rescue + # Finch raises when no connection frees up within the pool timeout (and for + # little else). Callers expect the hackney-era contract where a saturated + # pool is an {:error, _} to log and degrade on - a response without ENS + # names or tags - not an exception that kills the API request. + exception in RuntimeError -> {:error, exception} + catch + # other pool checkout failures (e.g. the pool process going down) exit + # instead of raising; degrade the same way + :exit, reason -> {:error, {:exit, reason}} end defp client(adapter_options) do diff --git a/apps/explorer/test/explorer/microservice_interfaces/http_client_test.exs b/apps/explorer/test/explorer/microservice_interfaces/http_client_test.exs index 44efb679b4f..4a58e27a532 100644 --- a/apps/explorer/test/explorer/microservice_interfaces/http_client_test.exs +++ b/apps/explorer/test/explorer/microservice_interfaces/http_client_test.exs @@ -14,7 +14,15 @@ defmodule Explorer.MicroserviceInterfaces.HttpClientTest do # a stub rather than an expectation: not every test here makes a request, # and the ones that do assert on :request_received themselves Bypass.stub(bypass, "GET", "/api/v1/metadata", fn conn -> - send(test_process, :request_received) + conn = Conn.fetch_query_params(conn) + + if conn.query_params["sleep"] do + send(test_process, :slow_request_started) + Process.sleep(300) + else + send(test_process, :request_received) + end + Conn.resp(conn, 200, "{}") end) @@ -36,42 +44,72 @@ defmodule Explorer.MicroserviceInterfaces.HttpClientTest do end describe "get/3" do - test "reuses the pooled connection instead of opening one per request", %{url: url} do - assert {:ok, %{status_code: 200}} = HttpClient.get(url) - assert_received :request_received - - assert {:ok, %{status_code: 200}} = HttpClient.get(url) - assert_received :request_received + test "reuses pooled connections instead of opening one per request", %{url: url} do + # one more sequential request than there are pools, so at least one pool + # must serve a second request over the connection its first one opened - + # which is what makes reuse skip DNS and the TCP + TLS handshake + for _ <- 1..(pool_count() + 1) do + assert {:ok, %{status_code: 200}} = HttpClient.get(url) + assert_received :request_received + end - # the second request was served over the connection the first one opened, - # which is what makes it skip DNS and the TCP + TLS handshake assert_received {:connection_reused, HttpClient.Finch} end end + describe "get/3 on a saturated pool" do + test "returns an error tuple instead of raising, so preloads can degrade", %{url: url} do + # a Finch instance with exactly one connection, so a single in-flight + # request saturates it + start_supervised!({Finch, name: __MODULE__.TinyFinch, pools: %{default: [size: 1, count: 1]}}) + + slow_response = + Task.async(fn -> HttpClient.get(url <> "?sleep=true", [], finch_instance: __MODULE__.TinyFinch) end) + + # wait until the slow request holds the pool's only connection + assert_receive :slow_request_started, 1_000 + + assert {:error, %RuntimeError{message: message}} = + HttpClient.get(url, [], finch_instance: __MODULE__.TinyFinch, checkout_timeout: 50) + + assert message =~ "excess queuing" + + assert {:ok, %{status_code: 200}} = Task.await(slow_response) + end + end + describe "proxy_get/3" do test "uses the Finch instance reserved for long-running requests", %{url: url} do - assert {:ok, %{status_code: 200}} = HttpClient.proxy_get(url) - assert {:ok, %{status_code: 200}} = HttpClient.proxy_get(url) + for _ <- 1..(pool_count() + 1) do + assert {:ok, %{status_code: 200}} = HttpClient.proxy_get(url) + end + assert_received {:connection_reused, HttpClient.ProxyFinch} - # nothing went through the pool that serves the latency-critical preloads + # nothing went through the instance that serves the latency-critical + # preloads refute_received {:connection_reused, HttpClient.Finch} end end describe "pool_child_specs/0" do - test "starts both Finch instances, sized from the MICROSERVICE_HTTP_POOL_SIZE configuration", %{url: url} do - max_connections = Application.get_env(:explorer, :microservice_http_pool_size) + test "starts both Finch instances, with MICROSERVICE_HTTP_POOL_SIZE connections split across MICROSERVICE_HTTP_POOL_COUNT pools", + %{url: url} do + total_size = Application.get_env(:explorer, :microservice_http_pool_size) + pool_count = pool_count() + per_pool_size = div(total_size, pool_count) # pools are started lazily per origin, so make one request through each assert {:ok, %{status_code: 200}} = HttpClient.get(url) assert {:ok, %{status_code: 200}} = HttpClient.proxy_get(url) for finch_name <- [HttpClient.Finch, HttpClient.ProxyFinch] do - assert {:ok, [%Finch.HTTP1.PoolMetrics{pool_size: ^max_connections}]} = - Finch.get_pool_status(finch_name, url) + assert {:ok, pool_metrics} = Finch.get_pool_status(finch_name, url) + assert length(pool_metrics) == pool_count + assert Enum.all?(pool_metrics, &(&1.pool_size == per_pool_size)) end end end + + defp pool_count, do: Application.get_env(:explorer, :microservice_http_pool_count) end diff --git a/config/runtime.exs b/config/runtime.exs index 3f5ceb1ce47..8e3fe03df05 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -349,7 +349,8 @@ config :explorer, shrink_internal_transactions_enabled: ConfigHelper.parse_bool_env_var("SHRINK_INTERNAL_TRANSACTIONS_ENABLED"), replica_max_lag: ConfigHelper.parse_time_env_var("REPLICA_MAX_LAG", "5m"), hackney_default_pool_size: ConfigHelper.parse_integer_env_var("HACKNEY_DEFAULT_POOL_SIZE", 1_000), - microservice_http_pool_size: ConfigHelper.parse_integer_env_var("MICROSERVICE_HTTP_POOL_SIZE", 1_000) + microservice_http_pool_size: ConfigHelper.parse_integer_env_var("MICROSERVICE_HTTP_POOL_SIZE", 1_000), + microservice_http_pool_count: ConfigHelper.parse_integer_env_var("MICROSERVICE_HTTP_POOL_COUNT", 20, min: 1) config :explorer, Explorer.Chain.Health.Monitor, check_interval: ConfigHelper.parse_time_env_var("HEALTH_MONITOR_CHECK_INTERVAL", "1m"), diff --git a/cspell.json b/cspell.json index 07ad111581c..e890a65a37c 100644 --- a/cspell.json +++ b/cspell.json @@ -140,6 +140,7 @@ "chakra", "chardata", "chartjs", + "checkin", "checkmarks", "checkproxyverification", "checksummed", diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index 4d469d88714..b446302e056 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -720,4 +720,5 @@ RELEASE_COOKIE=secret_cookie # RE_CAPTCHA_V3_CLIENT_KEY= # HACKNEY_DEFAULT_POOL_SIZE=1000 # MICROSERVICE_HTTP_POOL_SIZE=1000 +# MICROSERVICE_HTTP_POOL_COUNT=20 # UNIVERSAL_PROXY_CONFIG_URL= From b15cf670c3e192f2dd40e30f0fcda46b5453d477 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Mon, 17 Aug 2026 21:00:55 +0300 Subject: [PATCH 15/18] 11.2.7 --- .github/workflows/generate-swagger.yml | 2 +- .github/workflows/pre-release-arbitrum.yml | 2 +- .github/workflows/pre-release-celo.yml | 2 +- .github/workflows/pre-release-eden.yml | 2 +- .github/workflows/pre-release-eth.yml | 2 +- .github/workflows/pre-release-filecoin.yml | 2 +- .github/workflows/pre-release-fuse.yml | 2 +- .github/workflows/pre-release-gnosis.yml | 2 +- .github/workflows/pre-release-optimism.yml | 2 +- .github/workflows/pre-release-rootstock.yml | 2 +- .github/workflows/pre-release-scroll.yml | 2 +- .github/workflows/pre-release-zilliqa.yml | 2 +- .github/workflows/pre-release-zksync.yml | 2 +- .github/workflows/pre-release.yml | 2 +- .../publish-docker-image-custom-build.yml | 2 +- .../publish-docker-image-every-push.yml | 2 +- .../publish-docker-image-for-arbitrum.yml | 2 +- .../publish-docker-image-for-celo.yml | 2 +- .../publish-docker-image-for-eden.yml | 2 +- .../publish-docker-image-for-eth-sepolia.yml | 2 +- .../workflows/publish-docker-image-for-eth.yml | 2 +- .../publish-docker-image-for-filecoin.yml | 2 +- .../publish-docker-image-for-fuse.yml | 2 +- .../publish-docker-image-for-gnosis-chain.yml | 2 +- ...docker-image-for-optimism-exeperimental.yml | 2 +- .../publish-docker-image-for-optimism.yml | 2 +- .../publish-docker-image-for-rootstock.yml | 2 +- .../publish-docker-image-for-scroll.yml | 2 +- .../publish-docker-image-for-zetachain.yml | 2 +- .../publish-docker-image-for-zilliqa.yml | 2 +- .../publish-docker-image-for-zksync.yml | 2 +- .../workflows/publish-docker-image-old-ui.yml | 2 +- .github/workflows/release-arbitrum.yml | 2 +- .github/workflows/release-celo.yml | 2 +- .github/workflows/release-default.yml | 2 +- .github/workflows/release-eden.yml | 2 +- .github/workflows/release-eth.yml | 2 +- .github/workflows/release-filecoin.yml | 2 +- .github/workflows/release-fuse.yml | 2 +- .github/workflows/release-gnosis.yml | 2 +- .github/workflows/release-optimism.yml | 2 +- .github/workflows/release-rootstock.yml | 2 +- .github/workflows/release-scroll.yml | 2 +- .github/workflows/release-zetachain.yml | 2 +- .github/workflows/release-zilliqa.yml | 2 +- .github/workflows/release-zksync.yml | 2 +- CHANGELOG.md | 18 ++++++++++++++++++ apps/block_scout_web/mix.exs | 2 +- apps/ethereum_jsonrpc/mix.exs | 2 +- .../lib/explorer/token/metadata_retriever.ex | 2 +- apps/explorer/mix.exs | 2 +- apps/indexer/mix.exs | 2 +- apps/nft_media_handler/mix.exs | 2 +- apps/utils/mix.exs | 2 +- docker/Makefile | 2 +- mix.exs | 2 +- rel/config.exs | 2 +- 57 files changed, 74 insertions(+), 56 deletions(-) diff --git a/.github/workflows/generate-swagger.yml b/.github/workflows/generate-swagger.yml index c11110c77bc..02661c1fc27 100644 --- a/.github/workflows/generate-swagger.yml +++ b/.github/workflows/generate-swagger.yml @@ -17,7 +17,7 @@ on: env: OTP_VERSION: '27.3.4.6' ELIXIR_VERSION: '1.19.4' - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 jobs: matrix-builder: diff --git a/.github/workflows/pre-release-arbitrum.yml b/.github/workflows/pre-release-arbitrum.yml index 15e6ab60b30..ae392592858 100644 --- a/.github/workflows/pre-release-arbitrum.yml +++ b/.github/workflows/pre-release-arbitrum.yml @@ -21,7 +21,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/pre-release-celo.yml b/.github/workflows/pre-release-celo.yml index 5f263801ac4..34e069b4c8f 100644 --- a/.github/workflows/pre-release-celo.yml +++ b/.github/workflows/pre-release-celo.yml @@ -21,7 +21,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 API_GRAPHQL_MAX_COMPLEXITY: 10400 steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/pre-release-eden.yml b/.github/workflows/pre-release-eden.yml index d02f81adfda..5bb08e1e587 100644 --- a/.github/workflows/pre-release-eden.yml +++ b/.github/workflows/pre-release-eden.yml @@ -21,7 +21,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/pre-release-eth.yml b/.github/workflows/pre-release-eth.yml index bc5036d6a1d..7bc322a63ed 100644 --- a/.github/workflows/pre-release-eth.yml +++ b/.github/workflows/pre-release-eth.yml @@ -21,7 +21,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/pre-release-filecoin.yml b/.github/workflows/pre-release-filecoin.yml index 172d0ffd84c..3fd0f7da244 100644 --- a/.github/workflows/pre-release-filecoin.yml +++ b/.github/workflows/pre-release-filecoin.yml @@ -21,7 +21,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/pre-release-fuse.yml b/.github/workflows/pre-release-fuse.yml index ca4b4ee9479..c147575e6c7 100644 --- a/.github/workflows/pre-release-fuse.yml +++ b/.github/workflows/pre-release-fuse.yml @@ -21,7 +21,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/pre-release-gnosis.yml b/.github/workflows/pre-release-gnosis.yml index 71bee43be2c..5ea8c66333a 100644 --- a/.github/workflows/pre-release-gnosis.yml +++ b/.github/workflows/pre-release-gnosis.yml @@ -21,7 +21,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/pre-release-optimism.yml b/.github/workflows/pre-release-optimism.yml index 181b3b70117..a369b6892c5 100644 --- a/.github/workflows/pre-release-optimism.yml +++ b/.github/workflows/pre-release-optimism.yml @@ -21,7 +21,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/pre-release-rootstock.yml b/.github/workflows/pre-release-rootstock.yml index 10168fa8f6f..dc0199f8e0d 100644 --- a/.github/workflows/pre-release-rootstock.yml +++ b/.github/workflows/pre-release-rootstock.yml @@ -21,7 +21,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/pre-release-scroll.yml b/.github/workflows/pre-release-scroll.yml index ac0ebae203b..1023db45512 100644 --- a/.github/workflows/pre-release-scroll.yml +++ b/.github/workflows/pre-release-scroll.yml @@ -21,7 +21,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/pre-release-zilliqa.yml b/.github/workflows/pre-release-zilliqa.yml index cf4e801bac3..3983e8dc1a2 100644 --- a/.github/workflows/pre-release-zilliqa.yml +++ b/.github/workflows/pre-release-zilliqa.yml @@ -21,7 +21,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/pre-release-zksync.yml b/.github/workflows/pre-release-zksync.yml index d00214da841..568109ec61b 100644 --- a/.github/workflows/pre-release-zksync.yml +++ b/.github/workflows/pre-release-zksync.yml @@ -21,7 +21,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index e791f70add6..409e788bb05 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -21,7 +21,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/publish-docker-image-custom-build.yml b/.github/workflows/publish-docker-image-custom-build.yml index 1e1b7b05440..1c57a408310 100644 --- a/.github/workflows/publish-docker-image-custom-build.yml +++ b/.github/workflows/publish-docker-image-custom-build.yml @@ -14,7 +14,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/publish-docker-image-every-push.yml b/.github/workflows/publish-docker-image-every-push.yml index 9ccbbd8ebc0..a8019a3d4df 100644 --- a/.github/workflows/publish-docker-image-every-push.yml +++ b/.github/workflows/publish-docker-image-every-push.yml @@ -12,7 +12,7 @@ on: env: OTP_VERSION: '27.3.4.6' ELIXIR_VERSION: '1.19.4' - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 permissions: contents: read diff --git a/.github/workflows/publish-docker-image-for-arbitrum.yml b/.github/workflows/publish-docker-image-for-arbitrum.yml index 236e3882e52..0bb488771eb 100644 --- a/.github/workflows/publish-docker-image-for-arbitrum.yml +++ b/.github/workflows/publish-docker-image-for-arbitrum.yml @@ -14,7 +14,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 DOCKER_CHAIN_NAME: arbitrum steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/publish-docker-image-for-celo.yml b/.github/workflows/publish-docker-image-for-celo.yml index 101e764e9bb..9b446db1b43 100644 --- a/.github/workflows/publish-docker-image-for-celo.yml +++ b/.github/workflows/publish-docker-image-for-celo.yml @@ -14,7 +14,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 DOCKER_CHAIN_NAME: celo CHAIN_TYPE: optimism-celo API_GRAPHQL_MAX_COMPLEXITY: 10400 diff --git a/.github/workflows/publish-docker-image-for-eden.yml b/.github/workflows/publish-docker-image-for-eden.yml index e4d0dd4b8fb..b57ac97332b 100644 --- a/.github/workflows/publish-docker-image-for-eden.yml +++ b/.github/workflows/publish-docker-image-for-eden.yml @@ -14,7 +14,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 DOCKER_CHAIN_NAME: eden steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/publish-docker-image-for-eth-sepolia.yml b/.github/workflows/publish-docker-image-for-eth-sepolia.yml index 854a04a6a76..ea9561ea717 100644 --- a/.github/workflows/publish-docker-image-for-eth-sepolia.yml +++ b/.github/workflows/publish-docker-image-for-eth-sepolia.yml @@ -14,7 +14,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 DOCKER_CHAIN_NAME: eth-sepolia steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/publish-docker-image-for-eth.yml b/.github/workflows/publish-docker-image-for-eth.yml index fe8604b5edd..b29691400b5 100644 --- a/.github/workflows/publish-docker-image-for-eth.yml +++ b/.github/workflows/publish-docker-image-for-eth.yml @@ -14,7 +14,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 DOCKER_CHAIN_NAME: ethereum steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/publish-docker-image-for-filecoin.yml b/.github/workflows/publish-docker-image-for-filecoin.yml index 19875943e0a..bb84b762e06 100644 --- a/.github/workflows/publish-docker-image-for-filecoin.yml +++ b/.github/workflows/publish-docker-image-for-filecoin.yml @@ -13,7 +13,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 DOCKER_CHAIN_NAME: filecoin steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/publish-docker-image-for-fuse.yml b/.github/workflows/publish-docker-image-for-fuse.yml index 9ebdcae899c..1f044222c6e 100644 --- a/.github/workflows/publish-docker-image-for-fuse.yml +++ b/.github/workflows/publish-docker-image-for-fuse.yml @@ -14,7 +14,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 DOCKER_CHAIN_NAME: fuse steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/publish-docker-image-for-gnosis-chain.yml b/.github/workflows/publish-docker-image-for-gnosis-chain.yml index 242b5360b3a..0b297caabad 100644 --- a/.github/workflows/publish-docker-image-for-gnosis-chain.yml +++ b/.github/workflows/publish-docker-image-for-gnosis-chain.yml @@ -14,7 +14,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 DOCKER_CHAIN_NAME: xdai steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/publish-docker-image-for-optimism-exeperimental.yml b/.github/workflows/publish-docker-image-for-optimism-exeperimental.yml index a99ce76a372..77e1fe3369b 100644 --- a/.github/workflows/publish-docker-image-for-optimism-exeperimental.yml +++ b/.github/workflows/publish-docker-image-for-optimism-exeperimental.yml @@ -14,7 +14,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 DOCKER_CHAIN_NAME: optimism steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/publish-docker-image-for-optimism.yml b/.github/workflows/publish-docker-image-for-optimism.yml index a65b51b4238..07acf64c37c 100644 --- a/.github/workflows/publish-docker-image-for-optimism.yml +++ b/.github/workflows/publish-docker-image-for-optimism.yml @@ -14,7 +14,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 DOCKER_CHAIN_NAME: optimism steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/publish-docker-image-for-rootstock.yml b/.github/workflows/publish-docker-image-for-rootstock.yml index 5691c5cb0f0..dfc276fe069 100644 --- a/.github/workflows/publish-docker-image-for-rootstock.yml +++ b/.github/workflows/publish-docker-image-for-rootstock.yml @@ -14,7 +14,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 DOCKER_CHAIN_NAME: rsk steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/publish-docker-image-for-scroll.yml b/.github/workflows/publish-docker-image-for-scroll.yml index 7cb516c6f14..5e758bcc803 100644 --- a/.github/workflows/publish-docker-image-for-scroll.yml +++ b/.github/workflows/publish-docker-image-for-scroll.yml @@ -14,7 +14,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 DOCKER_CHAIN_NAME: scroll steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/publish-docker-image-for-zetachain.yml b/.github/workflows/publish-docker-image-for-zetachain.yml index b5cf2378222..bc4cfe7d9d0 100644 --- a/.github/workflows/publish-docker-image-for-zetachain.yml +++ b/.github/workflows/publish-docker-image-for-zetachain.yml @@ -14,7 +14,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 DOCKER_CHAIN_NAME: zetachain steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/publish-docker-image-for-zilliqa.yml b/.github/workflows/publish-docker-image-for-zilliqa.yml index 6e6e12f268c..097f7e3c6ca 100644 --- a/.github/workflows/publish-docker-image-for-zilliqa.yml +++ b/.github/workflows/publish-docker-image-for-zilliqa.yml @@ -14,7 +14,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 DOCKER_CHAIN_NAME: zilliqa steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/publish-docker-image-for-zksync.yml b/.github/workflows/publish-docker-image-for-zksync.yml index 2da7333f0ab..662d1f97949 100644 --- a/.github/workflows/publish-docker-image-for-zksync.yml +++ b/.github/workflows/publish-docker-image-for-zksync.yml @@ -13,7 +13,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 DOCKER_CHAIN_NAME: zksync steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/publish-docker-image-old-ui.yml b/.github/workflows/publish-docker-image-old-ui.yml index ea3b917a9f7..ebc007c2d3b 100644 --- a/.github/workflows/publish-docker-image-old-ui.yml +++ b/.github/workflows/publish-docker-image-old-ui.yml @@ -16,7 +16,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-arbitrum.yml b/.github/workflows/release-arbitrum.yml index b79243cd830..d515aa9d6b9 100644 --- a/.github/workflows/release-arbitrum.yml +++ b/.github/workflows/release-arbitrum.yml @@ -18,7 +18,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-celo.yml b/.github/workflows/release-celo.yml index 07bda2765c7..972243dabd7 100644 --- a/.github/workflows/release-celo.yml +++ b/.github/workflows/release-celo.yml @@ -18,7 +18,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 API_GRAPHQL_MAX_COMPLEXITY: 10400 steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/release-default.yml b/.github/workflows/release-default.yml index 51c66136c39..8a4d55c2c86 100644 --- a/.github/workflows/release-default.yml +++ b/.github/workflows/release-default.yml @@ -18,7 +18,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-eden.yml b/.github/workflows/release-eden.yml index 4f96d6f5ce7..dd18b2c1d5d 100644 --- a/.github/workflows/release-eden.yml +++ b/.github/workflows/release-eden.yml @@ -18,7 +18,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-eth.yml b/.github/workflows/release-eth.yml index 788ccc2b14d..a86060de68b 100644 --- a/.github/workflows/release-eth.yml +++ b/.github/workflows/release-eth.yml @@ -18,7 +18,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-filecoin.yml b/.github/workflows/release-filecoin.yml index d6ce05edaeb..8bc6ddfa803 100644 --- a/.github/workflows/release-filecoin.yml +++ b/.github/workflows/release-filecoin.yml @@ -18,7 +18,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-fuse.yml b/.github/workflows/release-fuse.yml index c24a6822cf8..cf2b09ca064 100644 --- a/.github/workflows/release-fuse.yml +++ b/.github/workflows/release-fuse.yml @@ -18,7 +18,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-gnosis.yml b/.github/workflows/release-gnosis.yml index d4c1d56bead..6a724e83c7e 100644 --- a/.github/workflows/release-gnosis.yml +++ b/.github/workflows/release-gnosis.yml @@ -18,7 +18,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-optimism.yml b/.github/workflows/release-optimism.yml index aef41a4e854..5698ed3acdb 100644 --- a/.github/workflows/release-optimism.yml +++ b/.github/workflows/release-optimism.yml @@ -18,7 +18,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-rootstock.yml b/.github/workflows/release-rootstock.yml index fe1152d1d86..0da07d47a20 100644 --- a/.github/workflows/release-rootstock.yml +++ b/.github/workflows/release-rootstock.yml @@ -18,7 +18,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-scroll.yml b/.github/workflows/release-scroll.yml index caeb787fc30..fd77fa5fc3b 100644 --- a/.github/workflows/release-scroll.yml +++ b/.github/workflows/release-scroll.yml @@ -18,7 +18,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-zetachain.yml b/.github/workflows/release-zetachain.yml index 919e777c1b6..5e327852df4 100644 --- a/.github/workflows/release-zetachain.yml +++ b/.github/workflows/release-zetachain.yml @@ -18,7 +18,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-zilliqa.yml b/.github/workflows/release-zilliqa.yml index 1a8b1b40c43..29804321a11 100644 --- a/.github/workflows/release-zilliqa.yml +++ b/.github/workflows/release-zilliqa.yml @@ -18,7 +18,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/.github/workflows/release-zksync.yml b/.github/workflows/release-zksync.yml index 83d48528891..b97c860dca5 100644 --- a/.github/workflows/release-zksync.yml +++ b/.github/workflows/release-zksync.yml @@ -18,7 +18,7 @@ jobs: name: Push Docker image to GitHub Container Registry runs-on: build env: - RELEASE_VERSION: 11.2.6 + RELEASE_VERSION: 11.2.7 steps: - uses: actions/checkout@v5 - name: Setup repo diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f916aa01df..6cb2255e538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 11.2.7 + +### 🚀 Features + +- Add /api/v2/transactions/{hash}/preview endpoint ([#14638](https://github.com/blockscout/blockscout/issues/14638), [#14703](https://github.com/blockscout/blockscout/issues/14703), [#14704](https://github.com/blockscout/blockscout/issues/14704)) + +### 🐛 Bug Fixes + +- Fix state changes for sponsored transactions ([#14702](https://github.com/blockscout/blockscout/issues/14702)) + +### ⚡ Performance + +- Reduce query count and payload in transaction API endpoint ([#14705](https://github.com/blockscout/blockscout/issues/14705)) +- Cache empty implementations of verified contracts longer ([#14696](https://github.com/blockscout/blockscout/issues/14696)) +- Fetch address existence checks in a single query ([#14694](https://github.com/blockscout/blockscout/issues/14694)) +- Reuse HTTP connections and parallelize microservice preloads ([#14689](https://github.com/blockscout/blockscout/issues/14689), [#14707](https://github.com/blockscout/blockscout/issues/14707)) + + ## 11.2.6 ### 🐛 Bug Fixes diff --git a/apps/block_scout_web/mix.exs b/apps/block_scout_web/mix.exs index fa41e768ee7..2f744302126 100644 --- a/apps/block_scout_web/mix.exs +++ b/apps/block_scout_web/mix.exs @@ -20,7 +20,7 @@ defmodule BlockScoutWeb.Mixfile do lockfile: "../../mix.lock", package: package(), start_permanent: Mix.env() == :prod, - version: "11.2.6", + version: "11.2.7", xref: [ exclude: [ Explorer.Chain.Beacon.Reader, diff --git a/apps/ethereum_jsonrpc/mix.exs b/apps/ethereum_jsonrpc/mix.exs index 485489e80f3..969822e6ebc 100644 --- a/apps/ethereum_jsonrpc/mix.exs +++ b/apps/ethereum_jsonrpc/mix.exs @@ -20,7 +20,7 @@ defmodule EthereumJSONRPC.MixProject do elixirc_paths: elixirc_paths(Mix.env()), lockfile: "../../mix.lock", start_permanent: Mix.env() == :prod, - version: "11.2.6" + version: "11.2.7" ] end diff --git a/apps/explorer/lib/explorer/token/metadata_retriever.ex b/apps/explorer/lib/explorer/token/metadata_retriever.ex index 51d26ead08a..0d97ce22b91 100644 --- a/apps/explorer/lib/explorer/token/metadata_retriever.ex +++ b/apps/explorer/lib/explorer/token/metadata_retriever.ex @@ -15,7 +15,7 @@ defmodule Explorer.Token.MetadataRetriever do @vm_execution_error "VM execution error" @invalid_base64_data "invalid data:application/json;base64" @invalid_ipfs_path "invalid ipfs path" - @default_headers [{"User-Agent", "blockscout-11.2.6"}] + @default_headers [{"User-Agent", "blockscout-11.2.7"}] # https://eips.ethereum.org/EIPS/eip-1155#metadata @erc1155_token_id_placeholder "{id}" diff --git a/apps/explorer/mix.exs b/apps/explorer/mix.exs index 16b17491184..70b87a8fddd 100644 --- a/apps/explorer/mix.exs +++ b/apps/explorer/mix.exs @@ -21,7 +21,7 @@ defmodule Explorer.Mixfile do lockfile: "../../mix.lock", package: package(), start_permanent: Mix.env() == :prod, - version: "11.2.6", + version: "11.2.7", xref: [exclude: [BlockScoutWeb.Routers.WebRouter.Helpers, Indexer.Helper, Indexer.Fetcher.InternalTransaction]] ] end diff --git a/apps/indexer/mix.exs b/apps/indexer/mix.exs index 94ce337f3bd..cfc7f0a0659 100644 --- a/apps/indexer/mix.exs +++ b/apps/indexer/mix.exs @@ -15,7 +15,7 @@ defmodule Indexer.MixProject do elixirc_paths: elixirc_paths(Mix.env()), lockfile: "../../mix.lock", start_permanent: Mix.env() == :prod, - version: "11.2.6", + version: "11.2.7", xref: [ exclude: [ Explorer.Chain.Optimism.Deposit, diff --git a/apps/nft_media_handler/mix.exs b/apps/nft_media_handler/mix.exs index 73439b1ff34..5351585bb8f 100644 --- a/apps/nft_media_handler/mix.exs +++ b/apps/nft_media_handler/mix.exs @@ -5,7 +5,7 @@ defmodule NFTMediaHandler.MixProject do def project do [ app: :nft_media_handler, - version: "11.2.6", + version: "11.2.7", build_path: "../../_build", config_path: "../../config/config.exs", deps_path: "../../deps", diff --git a/apps/utils/mix.exs b/apps/utils/mix.exs index 54fad5d6e18..b3604f8506e 100644 --- a/apps/utils/mix.exs +++ b/apps/utils/mix.exs @@ -5,7 +5,7 @@ defmodule Utils.MixProject do def project do [ app: :utils, - version: "11.2.6", + version: "11.2.7", build_path: "../../_build", # config_path: "../../config/config.exs", deps_path: "../../deps", diff --git a/docker/Makefile b/docker/Makefile index 4895bbfbf07..c481f1399ed 100644 --- a/docker/Makefile +++ b/docker/Makefile @@ -10,7 +10,7 @@ STATS_CONTAINER_NAME := stats STATS_DB_CONTAINER_NAME := stats-db PROXY_CONTAINER_NAME := proxy PG_CONTAINER_NAME := postgres -RELEASE_VERSION ?= '11.2.6' +RELEASE_VERSION ?= '11.2.7' TAG := $(RELEASE_VERSION)-commit-$(shell git log -1 --pretty=format:"%h") STABLE_TAG := $(RELEASE_VERSION) diff --git a/mix.exs b/mix.exs index 7d06eb55891..9221e1e8b40 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule BlockScout.Mixfile do [ # app: :block_scout, # aliases: aliases(config_env()), - version: "11.2.6", + version: "11.2.7", apps_path: "apps", deps: deps(), dialyzer: dialyzer(), diff --git a/rel/config.exs b/rel/config.exs index c6a5fba2c84..f44c8d5f20e 100644 --- a/rel/config.exs +++ b/rel/config.exs @@ -72,7 +72,7 @@ end # will be used by default release :blockscout do - set version: "11.2.6" + set version: "11.2.7" set applications: [ :runtime_tools, block_scout_web: :permanent, From 277b31e64630b56cb7c70bee3923817af25614d5 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Tue, 18 Aug 2026 13:23:00 +0300 Subject: [PATCH 16/18] Update CHANGELOG --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cb2255e538..6afdd341a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ - Fetch address existence checks in a single query ([#14694](https://github.com/blockscout/blockscout/issues/14694)) - Reuse HTTP connections and parallelize microservice preloads ([#14689](https://github.com/blockscout/blockscout/issues/14689), [#14707](https://github.com/blockscout/blockscout/issues/14707)) +### New ENV variables + +| Variable | Description | Parameters | +|-----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------| +| `CONTRACT_PROXY_EMPTY_IMPLEMENTATION_DATA_CACHE_TTL` | Empty probe results ("not a proxy") of smart-contracts verified on the address itself are refreshed with a dedicated, much longer TTL. Unverified contracts, bytecode twins, and real proxies keep the existing TTL behavior. [Time format](/setup/env-variables/backend-env-variables#time-format). Implemented in [#14689](https://github.com/blockscout/blockscout/pull/14689). | Version: v11.2.7+
Default: `1d`
Applications: API | +| `MICROSERVICE_HTTP_POOL_SIZE` | Total size of the keep-alive connection pools used by `Explorer.MicroserviceInterfaces.HttpClient` for BENS and Metadata requests. This total is split across multiple pools controlled by `MICROSERVICE_HTTP_POOL_COUNT`. Each individual pool size is calculated as `MICROSERVICE_HTTP_POOL_SIZE / MICROSERVICE_HTTP_POOL_COUNT`. Two logical pools are used: `:microservices` for short requests on the critical path and `:microservices_proxy` for requests proxied to a microservice on behalf of an API caller, so a handful of long-running proxied requests can no longer starve the preloads. Both pools are supervised from `Explorer.Application`. Implemented in [#14689](https://github.com/blockscout/blockscout/pull/14689) | Version: v11.2.7+
Default: `1000`
Applications: API | +| `MICROSERVICE_HTTP_POOL_COUNT` | Number of individual connection pools to split `MICROSERVICE_HTTP_POOL_SIZE` across. Pool splitting allows traffic to spread across multiple processes, reducing contention. Each pool size is calculated as `MICROSERVICE_HTTP_POOL_SIZE / MICROSERVICE_HTTP_POOL_COUNT`. Implemented in [#14707](https://github.com/blockscout/blockscout/pull/14707) | Version: v11.2.7+
Default: `4`
Applications: API, Indexer | + ## 11.2.6 @@ -46,6 +54,13 @@ - Bump ex_abi lib version ([#14671](https://github.com/blockscout/blockscout/issues/14671)) +### New ENV variables + +| Variable | Description | Parameters | +|-----------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------| +| `INDEXER_DISABLE_TOKEN_BALANCE_ON_DEMAND_FETCHER` | If `true`, `Indexer.Fetcher.OnDemand.TokenBalance` fetcher is disabled at runtime. | Version: v11.2.5\+
Default: `false`
Applications: Indexer | + + ## 11.2.4 ### 🚀 Features From 56389913143aff553c4e52654a5db1d8707357e8 Mon Sep 17 00:00:00 2001 From: Qwerty5Uiop <105209995+Qwerty5Uiop@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:27:19 +0400 Subject: [PATCH 17/18] chore: Fix transform addresses tests for zksync (#14717) --- .../lib/indexer/transform/addresses.ex | 172 +++++++++++++----- .../test/indexer/transform/addresses_test.exs | 59 ++++-- 2 files changed, 166 insertions(+), 65 deletions(-) diff --git a/apps/indexer/lib/indexer/transform/addresses.ex b/apps/indexer/lib/indexer/transform/addresses.ex index c0e021573ca..bd9b7192ade 100644 --- a/apps/indexer/lib/indexer/transform/addresses.ex +++ b/apps/indexer/lib/indexer/transform/addresses.ex @@ -232,6 +232,128 @@ defmodule Indexer.Transform.Addresses do defstruct pending: false + if @chain_type == :zksync do + @created_contract_doc """ + A contract created in an internal transaction has its `created_contract_address_hash` extracted. + The `created_contract_code` is ignored as it isn't the deployed bytecode on zksync. + + iex> Indexer.Transform.Addresses.extract_addresses( + ...> %{ + ...> internal_transactions: [ + ...> %{ + ...> block_number: 3, + ...> created_contract_address_hash: "0x0000000000000000000000000000000000000003", + ...> created_contract_code: "0x" + ...> } + ...> ] + ...> } + ...> ) + [ + %{ + fetched_coin_balance_block_number: 3, + hash: "0x0000000000000000000000000000000000000003" + } + ] + """ + + @merged_contract_code_doc """ + When a contract's code is fetched and the contract is used in transactions in the same fetched data, the + `contract_code` is merged with the greatest `block_number` + + iex> Indexer.Transform.Addresses.extract_addresses( + ...> %{ + ...> codes: [ + ...> %{ + ...> address: "0x0000000000000000000000000000000000000001", + ...> code: "0x" + ...> } + ...> ], + ...> transactions: [ + ...> %{ + ...> block_number: 2, + ...> from_address_hash: "0x0000000000000000000000000000000000000001", + ...> nonce: 4 + ...> }, + ...> %{ + ...> block_number: 3, + ...> to_address_hash: "0x0000000000000000000000000000000000000001", + ...> nonce: 5 + ...> } + ...> ] + ...> } + ...> ) + [ + %{ + contract_code: "0x", + fetched_coin_balance_block_number: 3, + hash: "0x0000000000000000000000000000000000000001", + nonce: 4 + } + ] + """ + else + @created_contract_doc """ + A contract created in an internal transaction has its `created_contract_address_hash` and + `created_contract_code` extracted. + + iex> Indexer.Transform.Addresses.extract_addresses( + ...> %{ + ...> internal_transactions: [ + ...> %{ + ...> block_number: 3, + ...> created_contract_address_hash: "0x0000000000000000000000000000000000000003", + ...> created_contract_code: "0x" + ...> } + ...> ] + ...> } + ...> ) + [ + %{ + contract_code: "0x", + fetched_coin_balance_block_number: 3, + hash: "0x0000000000000000000000000000000000000003" + } + ] + """ + + @merged_contract_code_doc """ + When a contract is created and then used in internal transactions and transaction in the same fetched data, the + `created_contract_code` is merged with the greatest `block_number` + + iex> Indexer.Transform.Addresses.extract_addresses( + ...> %{ + ...> internal_transactions: [ + ...> %{ + ...> block_number: 1, + ...> created_contract_code: "0x", + ...> created_contract_address_hash: "0x0000000000000000000000000000000000000001" + ...> } + ...> ], + ...> transactions: [ + ...> %{ + ...> block_number: 2, + ...> from_address_hash: "0x0000000000000000000000000000000000000001", + ...> nonce: 4 + ...> }, + ...> %{ + ...> block_number: 3, + ...> to_address_hash: "0x0000000000000000000000000000000000000001", + ...> nonce: 5 + ...> } + ...> ] + ...> } + ...> ) + [ + %{ + contract_code: "0x", + fetched_coin_balance_block_number: 3, + hash: "0x0000000000000000000000000000000000000001", + nonce: 4 + } + ] + """ + end + @doc """ Extract addresses from block, internal transaction, transaction, and log parameters. @@ -254,8 +376,7 @@ defmodule Indexer.Transform.Addresses do } ] - Internal transactions can have their `from_address_hash`, `to_address_hash` and/or `created_contract_address_hash` - extracted. + Internal transactions can have their `from_address_hash` and/or `to_address_hash` extracted. iex> Indexer.Transform.Addresses.extract_addresses( ...> %{ @@ -267,11 +388,6 @@ defmodule Indexer.Transform.Addresses do ...> %{ ...> block_number: 2, ...> to_address_hash: "0x0000000000000000000000000000000000000002" - ...> }, - ...> %{ - ...> block_number: 3, - ...> created_contract_address_hash: "0x0000000000000000000000000000000000000003", - ...> created_contract_code: "0x" ...> } ...> ] ...> } @@ -284,14 +400,10 @@ defmodule Indexer.Transform.Addresses do %{ fetched_coin_balance_block_number: 2, hash: "0x0000000000000000000000000000000000000002" - }, - %{ - contract_code: "0x", - fetched_coin_balance_block_number: 3, - hash: "0x0000000000000000000000000000000000000003" } ] + #{@created_contract_doc} Transactions can have their `from_address_hash` and/or `to_address_hash` extracted. iex> Indexer.Transform.Addresses.extract_addresses( @@ -399,41 +511,7 @@ defmodule Indexer.Transform.Addresses do } ] - When a contract is created and then used in internal transactions and transaction in the same fetched data, the - `created_contract_code` is merged with the greatest `block_number` - - iex> Indexer.Transform.Addresses.extract_addresses( - ...> %{ - ...> internal_transactions: [ - ...> %{ - ...> block_number: 1, - ...> created_contract_code: "0x", - ...> created_contract_address_hash: "0x0000000000000000000000000000000000000001" - ...> } - ...> ], - ...> transactions: [ - ...> %{ - ...> block_number: 2, - ...> from_address_hash: "0x0000000000000000000000000000000000000001", - ...> nonce: 4 - ...> }, - ...> %{ - ...> block_number: 3, - ...> to_address_hash: "0x0000000000000000000000000000000000000001", - ...> nonce: 5 - ...> } - ...> ] - ...> } - ...> ) - [ - %{ - contract_code: "0x", - fetched_coin_balance_block_number: 3, - hash: "0x0000000000000000000000000000000000000001", - nonce: 4 - } - ] - + #{@merged_contract_code_doc} All data must have some way of extracting the `fetched_coin_balance_block_number` or an `ArgumentError` will be raised when none of the supported extract formats matches the params. diff --git a/apps/indexer/test/indexer/transform/addresses_test.exs b/apps/indexer/test/indexer/transform/addresses_test.exs index bdb28fdb247..2c1daa8e809 100644 --- a/apps/indexer/test/indexer/transform/addresses_test.exs +++ b/apps/indexer/test/indexer/transform/addresses_test.exs @@ -4,6 +4,10 @@ defmodule Indexer.Transform.AddressesTest do alias Indexer.Transform.Addresses + @chain_type Application.compile_env(:explorer, :chain_type) + + @created_contract_code_extracted? @chain_type != :zksync + doctest Addresses describe "extract_addresses/1" do @@ -60,6 +64,22 @@ defmodule Indexer.Transform.AddressesTest do end test "differing contract code is ignored" do + expected_address = + if @created_contract_code_extracted? do + %{ + fetched_coin_balance_block_number: 2, + contract_code: "0x1", + hash: "0x0000000000000000000000000000000000000001", + nonce: nil + } + else + %{ + fetched_coin_balance_block_number: 2, + hash: "0x0000000000000000000000000000000000000001", + nonce: nil + } + end + assert Addresses.extract_addresses(%{ internal_transactions: [ %{ @@ -73,14 +93,7 @@ defmodule Indexer.Transform.AddressesTest do created_contract_address_hash: "0x0000000000000000000000000000000000000001" } ] - }) == [ - %{ - fetched_coin_balance_block_number: 2, - contract_code: "0x1", - hash: "0x0000000000000000000000000000000000000001", - nonce: nil - } - ] + }) == [expected_address] end test "extracts address params from code params" do @@ -154,6 +167,16 @@ defmodule Indexer.Transform.AddressesTest do block_reward_contract_beneficiaries: [beneficiary] } + created_contract_address = %{ + hash: internal_transaction.created_contract_address_hash, + fetched_coin_balance_block_number: internal_transaction.block_number + } + + created_contract_address = + if @created_contract_code_extracted?, + do: Map.put(created_contract_address, :contract_code, internal_transaction.created_contract_code), + else: created_contract_address + assert Addresses.extract_addresses(blockchain_data) == [ %{hash: block.miner_hash, fetched_coin_balance_block_number: block.number}, %{ @@ -164,11 +187,7 @@ defmodule Indexer.Transform.AddressesTest do hash: internal_transaction.to_address_hash, fetched_coin_balance_block_number: internal_transaction.block_number }, - %{ - hash: internal_transaction.created_contract_address_hash, - contract_code: internal_transaction.created_contract_code, - fetched_coin_balance_block_number: internal_transaction.block_number - }, + created_contract_address, %{ hash: transaction.from_address_hash, fetched_coin_balance_block_number: transaction.block_number, @@ -223,10 +242,14 @@ defmodule Indexer.Transform.AddressesTest do ] } - assert Addresses.extract_addresses(blockchain_data) == - [ - %{hash: hash, fetched_coin_balance_block_number: 34, contract_code: "code", nonce: 12} - ] + expected_address = + if @created_contract_code_extracted? do + %{hash: hash, fetched_coin_balance_block_number: 34, contract_code: "code", nonce: 12} + else + %{hash: hash, fetched_coin_balance_block_number: 34, nonce: 12} + end + + assert Addresses.extract_addresses(blockchain_data) == [expected_address] end test "only entities data defined in @entity_to_address_map are collected" do @@ -273,7 +296,7 @@ defmodule Indexer.Transform.AddressesTest do }) == [] end - if Application.compile_env(:explorer, :chain_type) == :eden do + if @chain_type == :eden do test "recipients of the batched calls are extracted with the coin balance block number" do first_call_hash = "0x11f60a633dd30a8d1a26dd6e20167a9293fb4647" second_call_hash = "0xcfc096e58b1f858e5a3ee88ecaeccb2b464625b5" From c60abc6d680a151abce634649283cf2934fee503 Mon Sep 17 00:00:00 2001 From: Victor Baranov Date: Tue, 18 Aug 2026 13:28:48 +0300 Subject: [PATCH 18/18] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6afdd341a3d..f3d2817e91d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Cache empty implementations of verified contracts longer ([#14696](https://github.com/blockscout/blockscout/issues/14696)) - Fetch address existence checks in a single query ([#14694](https://github.com/blockscout/blockscout/issues/14694)) - Reuse HTTP connections and parallelize microservice preloads ([#14689](https://github.com/blockscout/blockscout/issues/14689), [#14707](https://github.com/blockscout/blockscout/issues/14707)) +- Fix transform addresses tests for zksync ([#14717](https://github.com/blockscout/blockscout/pull/14717)) ### New ENV variables